Compare commits

..
Author SHA1 Message Date
mckero 691c5f5bb1 feat(status): add AMSAT satellite status tracking
New feature: displays recent AMSAT satellite activity from amsat.org API.
Shows 6-day status grid (Heard/Telemetry/Not Heard) + detailed reports.

Addresses PR #233 feedback:
- Uses Material3 colorScheme (tertiary/tertiaryContainer) for theme support
- HTTP in data layer via RemoteSource (Clean Architecture)
- SimpleDateFormat instead of manual date math
- Edge-to-edge support

Implementation:
- feature:status module (AMSAT status screen + legend)
- core:data AmSatRepository (API client using RemoteSource)
- core:domain models (SatStatus/SatReport/SatSlot/SatDay)
- MainTheme.kt: AMSAT colors mapped to colorScheme slots

Navigation intentionally not included - you mentioned potential UI
restructuring (Navigation Drawer). The screen is ready to integrate
wherever it fits your roadmap.

Ref: https://github.com/rt-bishop/Look4Sat/pull/233
2026-08-05 17:43:17 +00:00
Arty Bishop 8fbfcb3712 v4.4.5 - Pass progress hotfix, swapped modes/filter dialogs 2026-08-05 13:14:50 +02:00
Arty Bishop 602b1553a2 v4.4.4 - Icom CAT, components, filters and sources tweaks 2026-08-04 20:51:22 +02:00
atsunatsuandatsunatsu bd0881fb4b Added linear transponder Doppler calculator (#232)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:45:12 +02:00
atsunatsuandatsunatsu a9dd3e6e55 Localized pass date and time formats for Chinese (#231)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-04 18:12:10 +02:00
Lukas 905995ab44 Added configurable Radar offset to the sensors output (#230) 2026-08-04 18:08:44 +02:00
Arty Bishop f9806d7f7f Replaced the types selection dialog with modes selection 2026-07-31 16:04:39 +02:00
Arty Bishop 2d3adfc6a6 Added small tweaks to Sources, Components and strings 2026-07-31 12:27:25 +02:00
137 changed files with 1584 additions and 12547 deletions

No files matched your search

+4
View File
@@ -4,3 +4,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "never"
+40 -19
View File
@@ -4,15 +4,10 @@ on:
push:
tags:
- v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env:
TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
TAG_NAME: ${{ github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs:
release:
@@ -21,40 +16,66 @@ jobs:
contents: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Assemble APK
run: ./gradlew assembleRelease
- name: Assemble Artifacts
run: ./gradlew assembleRelease bundleRelease
- name: Sign APK
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
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}apksigner sign \
--ks keystore.jks \
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
--out "$SIGNED_APK" \
--out app/build/outputs/apk/release/look4sat.apk \
"$APK"
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
env:
GH_TOKEN: ${{ github.token }}
run: |
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
+2 -8
View File
@@ -1,9 +1,6 @@
# Built application files
*.ap_
# Hermes AI plans and metadata
.hermes/
# Files for the ART/Dalvik VM
*.dex
@@ -39,15 +36,12 @@ captures/
.idea/
# Keystore files
*.jks
*.keystore
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
/*.properties
/keystore.properties
/app/keystore.jks
# Hermes agent workspace (plans, local notes)
.hermes/
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
+48 -49
View File
@@ -8,16 +8,15 @@ All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) po
## Project Overview
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
satellites using TLE/OMM data from Celestrak/SatNOGS, calculates orbital positions via SGP4/SDP4 models, and displays
passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, satellite
ground track mapping, and pass predictions up to 10 days ahead. No ads, no tracking, no network required after initial
data download.
satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to
the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads,
no tracking, no network required after initial data download.
## Architecture
## Architecture & Design
**MVI (Model-View-Intent)** with unidirectional data flow:
- `State` data class → exposed via `StateFlow` from ViewModel
- `Action` sealed interface → user intents dispatched to ViewModel's `onAction()`
- `State` data class (named `<Feature>State`) exposed via `StateFlow` from ViewModel
- `Action` sealed interface (named `<Feature>Action`) dispatched to ViewModel's `onAction()`
- Jetpack Compose UI observes state and recomposes reactively
**Clean Architecture layers:**
@@ -34,9 +33,11 @@ data download.
| `feature:satellites` | Satellite list, filtering, selection |
| `feature:settings` | User preferences |
- `feature:*` modules depend only on `core:domain` + `core:presentation`. Features never depend on each other.
**Feature isolation:**
- `feature:*` modules depend only on `core:domain` and `core:presentation`.
- No feature-to-feature dependencies; cross-feature communication goes through core layers.
## Build & Run
## Build & Platform
```shell
# Debug build
@@ -50,54 +51,55 @@ data download.
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
- **Gradle**: Version catalog in `gradle/libs.versions.toml` + convention plugins in `build-logic/`
## Key Libraries
## Tech Stack
- **Compose** (BOM 2026.05.01) + Material3 Adaptive
- **Navigation3** (type-safe, uses `@Serializable` NavKeys)
- **Room** (KSP code generation) for local satellite/TLE storage
- **OkHttp** 5.x for TLE downloads
- **Navigation3**: Type-safe navigation with `@Serializable` nav keys
- **Room** (KSP code generation) for local satellite/orbital storage
- **OkHttp** 5.x for data downloads
- **OSMDroid** for map rendering
- **Kotlin Serialization** for navigation args and data parsing
- **Kotlin Serialization** for navigation args and parsing
- **Coroutines** + `StateFlow` for async/reactive patterns
## Conventions
- **Minimal dependencies**: Avoid adding libraries when a simple manual solution exists. Fewer deps = less maintenance.
- **DI**: Manual — ViewModels use companion `factory()` methods with `IMainContainer` interface.
- **Navigation**: Type-safe Compose Navigation3 with `@Serializable` data classes as nav keys.
- **State naming**: `<Feature>State` data class + `<Feature>Action` sealed interface per feature.
- **No feature-to-feature deps**: All cross-feature communication goes through core layers.
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh).
- **Localization**: 7 languages (en, es, ru, si, tr, uk, zh)
## Data Formats & Migration
**TLE vs. OMM/CSV format:**
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
- **TLE format**: Legacy 3-line element format limited by 5-digit NORAD IDs
- **OMM/CSV format**: Successor format with ISO 8601 timestamps and larger NORAD ID support
- New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
- `DataParser.kt` supports both via `parseTLEStream()` and `parseCSVStream()`
- Downloads auto-detect format; both produce identical `OrbitalData` objects
- Existing code already supports transparent source transition without feature changes
- Refresh orbital data weekly for accurate pass prediction (orbital decay)
- **TLE format**: Traditional 3-line element format (deprecated). NORAD catalog numbers are 5-digit integers, which
are running out of space. Celestrak has signaled that TLE format will eventually be phased out.
- **OMM/CSV format**: The future standard. CSV files contain the same orbital parameters as TLE but use ISO 8601
timestamps and support larger NORAD IDs. Celestrak and SatNOGS already provide OMM data in CSV format.
## Engineering Heuristics (Lazy = Efficient)
**Current implementation:**
- `DataParser.kt` handles both `parseTLEStream()` and `parseCSVStream()` seamlessly
- TLE data is downloaded from configured sources and stored in Room database
- When downloading satellite data, the app automatically detects format and parses accordingly
- Both formats produce identical `OrbitalData` objects, ensuring transparent format switching
- Treat "lazy" as efficient, not careless: the best code is the code never written.
- First understand the task and trace the real flow end-to-end, then climb this ladder:
1. Does this need to be built now? (YAGNI)
2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
3. Does Kotlin/Java stdlib already solve it?
4. Does the Android/platform API already solve it?
5. Does an already-installed dependency solve it?
6. Can this be simpler (including one-liner simple)?
7. Only then: write the minimum code that works.
- Prefer deletion to addition, boring over clever, and the fewest touched files.
- Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
- Manual DI only: ViewModels use companion `factory()` methods with `IMainContainer`.
- Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
- When two options are similar in size, choose the edge-case-correct one.
- If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
- For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"
**Migration path:**
As NORAD catalog space becomes constrained, OMM/CSV will become the primary format. Look4Sat is already positioned
to handle this transition without code changes — existing users can continue using TLE files while new sources
transition to OMM/CSV automatically.
## Bug-Fix Policy
## Code Style
- Prefer **short, focused functions** — single responsibility, easy to read.
- **Exceptions**: Composable functions and math-heavy algorithms (SGP4/SDP4) may be longer.
- Strict code style — no dead code, no unused imports, consistent formatting.
- Fix root cause, not just the reported symptom.
- If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
- The smallest correct diff wins only after behavior is understood.
## Roadmap
@@ -105,12 +107,9 @@ transition to OMM/CSV automatically.
## Gotchas
- Orbital math lives in `core:domain/predict/` — it's dense vector math (SGP4/SDP4). Tread carefully.
- TLE/OMM data must be refreshed weekly for accurate predictions (satellite orbits decay). TLE format is legacy and
will eventually be deprecated in favor of OMM/CSV as NORAD catalog numbers approach the 5-digit limit.
- Orbital math lives in `core:domain/predict/` — dense vector math (SGP4/SDP4). Tread carefully.
- SSTV decoding in `feature:radar` is experimental; image quality depends on signal strength during satellite pass.
- `build-logic/convention/` contains all shared Gradle configuration — edit there, not in individual modules.
- ProGuard is enabled for release builds — don't add reflection-based libs or any other dependencies without asking.
- `build-logic/convention/` contains shared Gradle configuration — edit there, not in individual modules.
## Copilot Working Mode: Code-Only
+77 -88
View File
@@ -1,29 +1,5 @@
LOOK4SAT (MCKERO6423 FORK) — LICENSING NOTICE
This repository combines two separately-licensed components:
1. Look4Sat application code (all modules except the DeepCW model)
— Copyright (C) 2019-2026 Arty Bishop (rt-bishop) and contributors
— Licensed under the GNU General Public License v3.0 (GPL-3.0)
2. The DeepCW neural decoding model in feature/cw/src/main/assets/deepcw/
— Copyright (C) e04 (https://github.com/e04/deepcw-engine)
— Licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only)
Because this combined work incorporates an AGPL-3.0 component, it is
distributed under the GNU Affero General Public License v3.0. GPL-3.0
Section 13 permits this combination; AGPL-3.0 Section 13 applies to the
combined work as a whole.
See feature/cw/licenses/NOTICE.md for model provenance, attribution, and
the applied int8 quantization. The original GPL-3.0 text for the Look4Sat
application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
--------------------------------------------------------------------------------
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -31,15 +7,17 @@ application code is preserved at feature/cw/licenses/Look4Sat-GPL-3.0.txt.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -48,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -84,7 +72,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -561,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -657,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
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 Affero General Public License for more details.
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-15
View File
@@ -34,21 +34,6 @@ It is now and always will be completely ad-free and open-source.
* 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.
## License
The Look4Sat application code is licensed under the GNU General Public License v3.0.
The CW decoder in `feature/cw` bundles the [DeepCW](https://github.com/e04/deepcw-engine)
neural decoding model, licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only). Because the combined work incorporates an AGPL-3.0 component, the
combined work is distributed under the
[GNU Affero General Public License v3.0](LICENSE) — GPL-3.0 Section 13 permits the
combination, and AGPL-3.0 Section 13 applies to the combined work as a whole.
Model provenance, attribution and the applied int8 quantization are documented in
[`feature/cw/licenses/NOTICE.md`](feature/cw/licenses/NOTICE.md); the original GPL-3.0
text is preserved at `feature/cw/licenses/Look4Sat-GPL-3.0.txt`. The CW model runs
locally on-device and does not provide services over a network.
## Star History
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
-47
View File
@@ -1,50 +1,3 @@
import java.util.Properties
plugins {
alias(libs.plugins.convention.applicationPlugin)
}
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
defaultConfig {
// ONNX Runtime 的 AAR 自带 4 个架构共 115MB 原生库(arm64 28M / armv7 20M /
// x86 33M / x86_64 34M)。x86 系列只有模拟器用得到, 全打包会让 APK 从 8MB
// 涨到 135MB。仅保留真机需要的两个 ABI。
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
}
androidResources {
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
localeFilters += listOf(
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
)
// DeepCW 模型必须以未压缩形式打包: ONNX Runtime 通过 mmap 直接读取
// assets, 压缩后无法映射会导致 createSession 失败。noCompress 只在
// 打包 APK 的 app 模块生效, 在 feature 库模块声明无效。
noCompress += "onnx"
}
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
// ONNX Runtime 走 JNI, R8 混淆会重命名 ai.onnxruntime.* 类导致 native
// 崩溃。convention 插件已开启 isMinifyEnabled, 必须补 keep 规则。
proguardFiles("proguard-rules.pro")
}
}
}
-13
View File
@@ -1,13 +0,0 @@
# ProGuard / R8 rules for the Look4Sat application module.
#
# NOTE: release builds enable minification (isMinifyEnabled=true in the
# convention plugin), so anything whose classes are resolved reflectively or
# through JNI by name MUST be kept here.
# ONNX Runtime (ai.onnxruntime): the Java binding is backed by JNI. Native code
# resolves Java methods/classes by their original names; R8 renaming or
# stripping them causes a hard crash at runtime with no Java stack trace.
# This rule is required by the ONNX Runtime docs for minified Android builds.
# https://onnxruntime.ai/docs/get-started/with-java.html
-keep class ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
+2 -10
View File
@@ -14,10 +14,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
<application
android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
@@ -28,7 +25,6 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Look4Sat.SplashScreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -46,11 +42,7 @@
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat.bg7nta" />
android:value="com.rtbishop.look4sat" />
<service
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -1,190 +0,0 @@
package com.rtbishop.look4sat.app
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.widget.Toast
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
import com.rtbishop.look4sat.core.data.aprs.AprsStore
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
import com.rtbishop.look4sat.core.data.aprs.AprsState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* APRS foreground service: kept alive by a system notification; keeps working across pages.
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
*/
class AprsForegroundService : Service() {
companion object {
const val ACTION_START = AprsStore.ACTION_START
const val ACTION_STOP = AprsStore.ACTION_STOP
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
const val CHANNEL_ID = "aprs_service"
const val NOTIF_ID = 101
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reporter: AprsReporter? = null
private var lastState: AprsState = AprsState.Idle
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> stopReporting()
ACTION_REPORT_NOW -> {
if (reporter == null) {
// Service not running: start it first (Toast hint when not configured)
startReporting()
}
reporter?.reportNow()
}
else -> startReporting()
}
return START_STICKY
}
private fun startReporting() {
val cfg = AprsStore.loadConfig(this)
if (!cfg.enabled || cfg.callsign.isBlank()) {
runCatching {
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
}
stopSelf()
return
}
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
AprsStore.saveLastReport(this, report.ok, report.detail)
updateNotification(cfg)
// Report result always surfaces: success = short Toast, failure = long Toast + reason
val msg = if (report.ok) {
getString(R.string.aprs_toast_ok)
} else {
getString(R.string.aprs_toast_fail, report.detail)
}
runCatching {
Toast.makeText(this, msg,
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
}
}
)
reporter = rep
rep.start()
}
private fun stopReporting() {
reporter?.stop()
reporter = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun startForegroundWithNotification(cfg: AprsConfig) {
try {
val notif = buildNotification(cfg)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notif)
}
} catch (e: Exception) {
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
stopSelf()
}
}
private fun buildNotification(cfg: AprsConfig): Notification {
val pi = PendingIntent.getActivity(
this, 0, packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val stopPi = PendingIntent.getService(
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE
)
val stateText = when (lastState) {
AprsState.Connected -> getString(R.string.aprs_notif_connected)
AprsState.Error -> getString(R.string.aprs_notif_error)
else -> getString(R.string.aprs_notif_running)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_radios)
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
.setContentText(stateText)
.setContentIntent(pi)
.setOngoing(true)
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
.build()
}
private fun updateNotification(cfg: AprsConfig) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIF_ID, buildNotification(cfg))
}
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
private fun stationPosition(): Pair<Double, Double>? {
// 1. Station QTH (position set in settings)
val station = runCatching {
val container = (application as MainApplication).getMainContainer()
container.settingsRepo.stationPosition.value
}.getOrNull()
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
return Pair(station.latitude, station.longitude)
}
// 2. Fallback: last live GPS position
return runCatching {
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
val providers = listOf(
android.location.LocationManager.GPS_PROVIDER,
android.location.LocationManager.NETWORK_PROVIDER
)
for (p in providers) {
val loc = lm.getLastKnownLocation(p) ?: continue
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
return Pair(loc.latitude, loc.longitude)
}
}
null
}.getOrNull()
}
private fun createChannel() {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID, getString(R.string.aprs_notif_channel),
NotificationManager.IMPORTANCE_LOW
)
nm.createNotificationChannel(channel)
}
override fun onDestroy() {
reporter?.stop()
reporter = null
super.onDestroy()
}
}
@@ -30,29 +30,10 @@ class MainApplication : Application(), IContainerProvider {
private lateinit var container: IMainContainer
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
private fun installCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
val log = StringBuilder()
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
log.append("Thread: ").append(thread.name).append("\n")
val sw = java.io.StringWriter()
throwable.printStackTrace(java.io.PrintWriter(sw))
log.append(sw.toString()).append("\n")
val file = java.io.File(filesDir, "crash_log.txt")
file.appendText(log.toString())
}
defaultHandler?.uncaughtException(thread, throwable)
}
}
override fun getMainContainer(): IMainContainer = container
override fun onCreate() {
super.onCreate()
installCrashHandler()
container = MainContainer(this)
// trigger automatic update every 48 hours
container.appScope.launch { checkAutoUpdate() }
@@ -17,23 +17,16 @@
*/
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.RepeatMode
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
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.slideOutHorizontally
import androidx.compose.animation.togetherWith
@@ -60,9 +53,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -73,16 +63,13 @@ 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.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -90,15 +77,9 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
import com.rtbishop.look4sat.core.presentation.Screen
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
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.mutual.MutualScreen
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
import com.rtbishop.look4sat.feature.passes.PassesDestination
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.settings.SettingsDestination
@@ -144,40 +125,12 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
.sortedBy { screen ->
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
if (idx != -1) idx
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
if (it != -1) it else Int.MAX_VALUE
}
}
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
val mainNavItems = remember(allNavItems, subOrder) {
allNavItems.filter { it.screenId !in subOrder }.take(5)
}
val moreNavItems = remember(allNavItems, subOrder) {
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
}
var moreExpanded by remember { mutableStateOf(false) }
// Intercept Back while the More menu is open: close the menu first
BackHandler(enabled = moreExpanded) { moreExpanded = false }
// Activity-scoped so the mutual query results survive navigation to Radar and back
val mutualViewModel: MutualViewModel = viewModel(
viewModelStoreOwner = context as ViewModelStoreOwner,
factory = MutualViewModel.factory(container)
)
CompositionLocalProvider(
LocalElevationThresholds provides ElevationThresholds(
@@ -187,15 +140,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
) {
NavigationSuiteScaffold(
navigationSuiteItems = {
mainNavItems.forEach { screen ->
navItems.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.AmSat -> screen is Screen.AmSat
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
@@ -206,26 +155,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
selected = isSelected,
onClick = {
if (isSelected) return@item
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
}
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
if (moreNavItems.isNotEmpty()) {
item(
icon = {
Icon(
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
)
},
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
selected = moreExpanded,
onClick = { moreExpanded = !moreExpanded }
)
}
},
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
@@ -236,137 +170,90 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Box {
Column(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f).fillMaxWidth(),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// 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()
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
// navigateToRadar()
}
}
)
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
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(
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, 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
)
entry<Screen.Radar> {
RadarDestination(navigateUp = navigateBack)
}
entry<Screen.Map> {
MapDestination()
}
entry<Screen.Settings> {
SettingsDestination()
}
}
}
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
AnimatedVisibility(
visible = moreExpanded,
modifier = Modifier.fillMaxSize(),
enter = expandVertically(
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
) + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
MoreMenuPopup(
items = moreNavItems,
currentKey = currentKey,
onDismiss = { moreExpanded = false },
onSelect = { screen ->
moreExpanded = false
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
// Radio tracking status banner
if (trackingState.isActive) {
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 1f, targetValue = 0.4f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
), label = "pulseAlpha"
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
val pass = trackingState.currentPass
if (pass != null) {
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(
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 - bottom-nav "More" second-level menu popup panel (4.5.1).
*
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
* (MainScreen's AnimatedVisibility + spring).
*/
package com.rtbishop.look4sat
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation3.runtime.NavKey
import com.rtbishop.look4sat.core.presentation.Screen
@Composable
fun MoreMenuPopup(
items: List<Screen>,
currentKey: NavKey?,
onDismiss: () -> Unit,
onSelect: (Screen) -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
.clickable(onClick = onDismiss)
) {
Card(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(12.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
items.forEach { screen ->
val isSelected = when (currentKey) {
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Radar -> screen is Screen.Radar
is Screen.Mutual -> screen is Screen.Mutual
is Screen.CwDecode -> screen is Screen.CwDecode
is Screen.WavelogLog -> screen is Screen.WavelogLog
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(screen) }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId),
tint = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(screen.titleResId),
fontSize = 14.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Text(
text = "›",
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
-9
View File
@@ -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>
@@ -31,15 +31,11 @@ internal class ApplicationPlugin : Plugin<Project> {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
implementation(project(":feature:cw"))
implementation(project(":feature:map"))
implementation(project(":feature:mutual"))
implementation(project(":feature:passes"))
implementation(project(":feature:radar"))
implementation(project(":feature:roaming"))
implementation(project(":feature:satellites"))
implementation(project(":feature:settings"))
implementation(project(":feature:status"))
implementation(libs.androidx.core.splashscreen)
implementation(libs.compose.material3.adaptive)
implementation(libs.compose.navigation3)
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = libs.versions.applicationId.get()
applicationId = libs.versions.packageName.get()
minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
-6
View File
@@ -5,9 +5,3 @@ plugins {
android {
namespace = "com.rtbishop.look4sat.core.data"
}
dependencies {
// DeepCW 神经网络 CW 解码推理。ONNX 推理属 Android 平台依赖, 放此处而非
// core:domain —— 后者须保持纯 Kotlin/JVM 以留 KMP 迁移余地 (见 AGENTS.md)。
implementation(libs.other.onnxruntime)
}
@@ -1,106 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
/**
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
*/
class AprsIsClient(
private val host: String,
private val port: Int,
private val callsign: String,
private val ssid: String,
private val passcode: Int,
private val version: String,
private val filter: String = "",
private val timeoutSec: Int = 120
) {
private var socket: Socket? = null
private var writer: PrintWriter? = null
private var reader: BufferedReader? = null
private val lock = Any()
val isConnected: Boolean
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
/** Connect + login (synchronous/blocking; call from a background thread) */
@Throws(Exception::class)
fun connect() {
disconnect()
val s = Socket()
s.connect(InetSocketAddress(host, port), 30_000)
s.soTimeout = timeoutSec * 1000
s.tcpNoDelay = true
synchronized(lock) {
socket = s
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
}
// Login line
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
writer?.println(login)
// Read the login response (aprsc replies # logresp ... verified/unverified)
runCatching {
s.soTimeout = 8000
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("unverified", ignoreCase = true))) {
throw IllegalArgumentException(resp.trim())
}
// Restore timeout
s.soTimeout = timeoutSec * 1000
}
}
/**
* Sends one APRS packet (one line) and tries to read the server ack.
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
*/
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
synchronized(lock) {
val w = writer ?: return null
w.println(packetLine)
if (w.checkError()) return Pair(false, "write failed")
}
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
return runCatching {
val s = socket ?: return@runCatching Pair(true, "OK")
val oldTimeout = s.soTimeout
s.soTimeout = 3000
try {
val resp = reader?.readLine()
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
resp.contains("error", ignoreCase = true))) {
Pair(false, resp.trim())
} else {
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
}
} finally {
s.soTimeout = oldTimeout
}
}.getOrElse { Pair(true, "OK") }
}
/** Read one line (server response; throws on timeout) */
fun readLine(): String? {
return reader?.readLine()
}
fun disconnect() {
synchronized(lock) {
runCatching { writer?.close() }
runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
socket = null
}
}
}
@@ -1,126 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** APRS connection state */
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
/** APRS config (persisted in SharedPreferences, saved as filled) */
data class AprsConfig(
val enabled: Boolean = false,
val server: String = "euro.aprs2.net",
val port: Int = 14580,
val callsign: String = "",
val ssid: String = "",
val passcode: String = "",
val intervalMin: Int = 5,
val statusText: String = "Look4Sat APRS",
val symbolTable: String = "/",
val symbolCode: String = ">",
val includeCourseSpeed: Boolean = true,
val includeAltitude: Boolean = true
)
/** APRS report result */
data class AprsReport(
val timestamp: Long,
val packet: String,
val ok: Boolean,
val detail: String
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: AprsIsClient? = null
private var job: Job? = null
private var manualJob: Job? = null
val isRunning: Boolean get() = job?.isActive == true
/** Start periodic reporting (called by the foreground service) */
fun start() {
stop()
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) {
onState(AprsState.Error)
return
}
job = scope.launch {
while (isActive) {
reportOnce()
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
}
}
}
fun stop() {
job?.cancel()
job = null
runCatching { client?.disconnect() }
client = null
onState(AprsState.Idle)
}
/** Trigger one report manually (immediately, without waiting for the cycle) */
fun reportNow() {
manualJob?.cancel()
manualJob = scope.launch { reportOnce() }
}
private suspend fun reportOnce() {
val cfg = configProvider()
if (!cfg.enabled || cfg.callsign.isBlank()) return
onState(AprsState.Connecting)
try {
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
callsign = cfg.callsign,
ssid = cfg.ssid,
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
version = "Look4Sat 4.5.4"
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
val pos = positionProvider()
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
val result = c.sendPacket(packetLine)
val ok = result?.first == true
val detail = result?.second ?: "no connection"
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
} catch (e: Exception) {
runCatching { client?.disconnect() }
client = null
onState(AprsState.Error)
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
}
}
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
val pos = AprsPosition(
latitude = lat ?: 0.0,
longitude = lon ?: 0.0,
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
)
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
}
}
@@ -1,83 +0,0 @@
package com.rtbishop.look4sat.core.data.aprs
import android.content.Context
/**
* APRS config storage + service action constants (shared by feature/settings and the app service,
* so feature never depends on app).
*/
object AprsStore {
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
private const val PREFS = "aprs_config"
private const val KEY_ENABLED = "enabled"
private const val KEY_SERVER = "server"
private const val KEY_PORT = "port"
private const val KEY_CALLSIGN = "callsign"
private const val KEY_SSID = "ssid"
private const val KEY_PASSCODE = "passcode"
private const val KEY_INTERVAL = "interval"
private const val KEY_STATUS = "status"
private const val KEY_SYMBOL_TABLE = "symbol_table"
private const val KEY_SYMBOL_CODE = "symbol_code"
private const val KEY_LAST_TIME = "last_report_time"
private const val KEY_LAST_OK = "last_report_ok"
private const val KEY_LAST_DETAIL = "last_report_detail"
/** Read config (saved as filled; no need to re-enter each time) */
fun loadConfig(context: Context): AprsConfig {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AprsConfig(
enabled = p.getBoolean(KEY_ENABLED, false),
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
port = p.getInt(KEY_PORT, 14580),
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
ssid = p.getString(KEY_SSID, "") ?: "",
passcode = p.getString(KEY_PASSCODE, "") ?: "",
intervalMin = p.getInt(KEY_INTERVAL, 5),
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
)
}
/** Last report result (shown on the settings card) */
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
fun loadLastReport(context: Context): LastReport {
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return LastReport(
time = p.getLong(KEY_LAST_TIME, 0L),
ok = p.getBoolean(KEY_LAST_OK, false),
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
)
}
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
.putBoolean(KEY_LAST_OK, ok)
.putString(KEY_LAST_DETAIL, detail)
.apply()
}
/** Save config */
fun saveConfig(context: Context, cfg: AprsConfig) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putBoolean(KEY_ENABLED, cfg.enabled)
.putString(KEY_SERVER, cfg.server)
.putInt(KEY_PORT, cfg.port)
.putString(KEY_CALLSIGN, cfg.callsign)
.putString(KEY_SSID, cfg.ssid)
.putString(KEY_PASSCODE, cfg.passcode)
.putInt(KEY_INTERVAL, cfg.intervalMin)
.putString(KEY_STATUS, cfg.statusText)
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
.apply()
}
}
@@ -1,336 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.cw
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.ICwDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.nio.FloatBuffer
/**
* CW decoder backed by the DeepCW neural network (AGPL-3.0, see
* `feature/cw/licenses/NOTICE.md`).
*
* The model classifies a whole audio segment at once rather than streaming
* sample by sample, and it revises earlier characters once more context
* arrives. Incremental stitching therefore produces duplicated callsigns —
* measured character error rates of 67-294% against 0% for whole-segment
* decoding. Instead a [CwDeepBuffer] holds the last 20 seconds and the whole
* window is re-decoded every 1.5 seconds, replacing [decodedText] outright.
*
* The model's fixed 400-1200 Hz analysis window means pitch detection is built
* in; no spectral peak tracking or squelch gating is needed.
*/
class CwDeepDecoder(context: Context) : ICwDecoder {
private companion object {
const val TAG = "CwDeepDecoder"
const val MODEL_ASSET = "deepcw/model.onnx"
const val METADATA_ASSET = "deepcw/model.onnx.json"
/** Evicted audio is decoded into permanent history once this much accumulates. */
const val ARCHIVE_SECONDS = 15.0
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
}
private val _decodedText = MutableStateFlow("")
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow()
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
private val _signalStrength = MutableStateFlow(0f)
override val signalStrength: StateFlow<Float> = _signalStrength.asStateFlow()
private val _lastInferenceMs = MutableStateFlow(0)
override val lastInferenceMs: StateFlow<Int> = _lastInferenceMs.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
override val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val buffer = CwDeepBuffer()
/**
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
* keeps the extra inference cheap (short window) while long enough to be
* decoded accurately — the content has already been through the 20 s window
* many times, so a slightly shorter archive decode loses almost nothing.
*/
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
private var archiveSize = 0
/** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex()
private var environment: OrtEnvironment? = null
private var session: OrtSession? = null
private var chars: List<String> = emptyList()
private var blankIndex = 41
private var inputName = "spectrogram"
private var outputName = "log_probs"
private val appContext = context.applicationContext
private var loadAttempted = false
init {
CwProbe.init(appContext)
CwProbe.step("decoder_constructed")
}
/**
* Loads metadata and the ONNX session on first use.
*
* Deliberately not done in `init`: loading pulls in ONNX Runtime's native
* library, and a failure there surfaces as [UnsatisfiedLinkError]. Thrown
* from a constructor it would take down the whole composable that created
* the decoder, so the work happens here where it can be reported through
* [errorMessage] instead.
*
* @return true when the session is ready to run.
*/
private fun ensureLoaded(): Boolean {
if (session != null) return true
if (loadAttempted) return false
loadAttempted = true
CwProbe.step("load_begin")
try {
val metadata = JSONObject(
appContext.assets.open(METADATA_ASSET).bufferedReader().use { it.readText() }
)
val charArray = metadata.getJSONArray("chars")
chars = List(charArray.length()) { charArray.getString(it) }
blankIndex = metadata.getInt("blank_index")
inputName = metadata.getString("onnx_input_name")
outputName = metadata.getString("onnx_output_name")
val modelBytes = appContext.assets.open(MODEL_ASSET).use { it.readBytes() }
val env = OrtEnvironment.getEnvironment()
environment = env
val options = OrtSession.SessionOptions().apply {
// Keep a core free for audio capture and the UI; the default
// would spread inference across every core on the device.
val threads = (Runtime.getRuntime().availableProcessors() - 1).coerceIn(1, 4)
setIntraOpNumThreads(threads)
}
session = env.createSession(modelBytes, options)
CwProbe.step("load_session_ok")
Log.i(TAG, "DeepCW ready: ${modelBytes.size} bytes, ${chars.size} classes")
return true
} catch (t: Throwable) {
// Catches UnsatisfiedLinkError (missing/mismatched .so) as well as
// asset and session failures.
CwProbe.step("load_failed:${t.javaClass.simpleName}")
Log.e(TAG, "DeepCW model failed to load", t)
_errorMessage.value =
"CW model failed to load: ${t.message ?: t.javaClass.simpleName}"
// Persist the failure for devices without logcat access.
runCatching {
val sw = java.io.StringWriter()
t.printStackTrace(java.io.PrintWriter(sw))
java.io.File(appContext.filesDir, "deepcw_load_error.txt")
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
}
return false
}
}
override suspend fun processBuffer(samples: FloatArray, sampleRate: Int) {
if (samples.isEmpty()) return
if (!ensureLoaded()) return
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val shouldRedecode = buffer.append(resampled)
// Archive audio that scrolled out of the live window. It is decoded once
// when a full archive chunk has accumulated, so old text does not vanish.
val overflow = buffer.drainOverflow()
if (overflow.isNotEmpty()) {
for (v in overflow) {
if (archiveSize < archiveBuffer.size) archiveBuffer[archiveSize++] = v
}
if (archiveSize >= ARCHIVE_THRESHOLD) {
val audio = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
try {
archiveDecode(audio)
} catch (t: Throwable) {
if (t is CancellationException) throw t
Log.e(TAG, "archive decode failed", t)
}
}
}
if (!shouldRedecode || !buffer.hasEnoughAudio) return
// Drop this cycle rather than queue when the previous run is still going.
if (!inferenceLock.tryLock()) {
Log.d(TAG, "inference still running, skipping this interval")
return
}
try {
decodeWindow(buffer.snapshot())
} catch (t: Throwable) {
// Cancellation is normal when the user pauses: the capture coroutine
// is cancelled while an inference is in flight. Never swallow it as
// a decode error — rethrow so the coroutine machinery works, and do
// not flash a spurious "decode failed" banner.
if (t is CancellationException) throw t
Log.e(TAG, "inference failed", t)
_errorMessage.value = "CW decode failed: ${t.message ?: t.javaClass.simpleName}"
runCatching {
val sw = java.io.StringWriter()
t.printStackTrace(java.io.PrintWriter(sw))
java.io.File(appContext.filesDir, "deepcw_infer_error.txt")
.writeText("${t.javaClass.name}: ${t.message}\n${sw}\n")
}
} finally {
inferenceLock.unlock()
}
}
private suspend fun decodeWindow(window: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
CwProbe.step("infer_begin frames=${window.size}")
val spectrogram = CwDeepSpectrogram.compute(window)
val text = runInference(activeSession, activeEnvironment, spectrogram)
// Replace, never append: the model rewrites earlier characters as more
// context arrives, so appending would leave stale guesses on screen.
_decodedText.value = text
updateSignalMetrics(spectrogram)
}
/**
* Decode a chunk of audio that has scrolled out of the live window and
* append it to [historyText]. Unlike the live window this never replaces —
* the archived audio is final, so its text is permanent.
*/
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram)
if (text.isNotEmpty()) {
_historyText.value += text
}
}
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
private fun runInference(
activeSession: OrtSession,
activeEnvironment: OrtEnvironment,
spectrogram: Array<FloatArray>
): String {
val frames = spectrogram.size
val bins = CwDeepSpectrogram.FREQUENCY_BINS
val flat = FloatBuffer.allocate(frames * bins)
for (frame in spectrogram) flat.put(frame)
flat.rewind()
val shape = longArrayOf(1, 1, frames.toLong(), bins.toLong())
val startedAt = System.currentTimeMillis()
val text: String
OnnxTensor.createTensor(activeEnvironment, flat, shape).use { input ->
activeSession.run(mapOf(inputName to input)).use { result ->
@Suppress("UNCHECKED_CAST")
val logits = result[outputName].get().value as Array<Array<FloatArray>>
text = CwCtcDecoder.greedy(logits, chars, blankIndex)
}
}
_lastInferenceMs.value = (System.currentTimeMillis() - startedAt).toInt()
CwProbe.step("infer_done ms=${_lastInferenceMs.value}")
return text
}
/**
* Report the loudest bin as the tone pitch and its prominence over the
* window mean as a 0..1 strength, purely for the UI readout.
*/
private fun updateSignalMetrics(spectrogram: Array<FloatArray>) {
if (spectrogram.isEmpty()) return
var bestBin = 0
var bestValue = 0f
var total = 0f
var count = 0
for (frame in spectrogram) {
for (bin in frame.indices) {
val value = frame[bin]
total += value
count++
if (value > bestValue) {
bestValue = value
bestBin = bin
}
}
}
if (count == 0 || bestValue <= 0f) return
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
// Relative bin 0 is 400 Hz; absolute bin index is 32 + bestBin.
val absoluteBin = 32 + bestBin
_estimatedPitch.value = (absoluteBin * binHz).toFloat()
val mean = total / count
_signalStrength.value = ((bestValue - mean) / bestValue).coerceIn(0f, 1f)
}
override fun reset() {
buffer.reset()
_decodedText.value = ""
_historyText.value = ""
archiveSize = 0
_estimatedPitch.value = null
_signalStrength.value = 0f
_lastInferenceMs.value = 0
}
override fun close() {
try {
session?.close()
} catch (t: Throwable) {
Log.w(TAG, "session close failed", t)
}
session = null
environment = null
}
}
@@ -1,46 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.cw
import android.content.Context
import android.util.Log
/**
* Minimal crash-probe logger for diagnosing crashes that produce no Java
* stack trace (native faults, low-memory kills). Each step appends one line
* to `files/probe_cw.txt`; if the process dies mid-way the last line shows
* exactly where. No adb or logcat required.
*/
internal object CwProbe {
private var dir: java.io.File? = null
fun init(context: Context) {
dir = context.filesDir
}
fun step(label: String) {
val target = dir ?: return
runCatching {
val line = "${System.currentTimeMillis()} $label"
val file = java.io.File(target, "probe_cw.txt")
file.appendText("$line\n")
Log.i("CwProbe", line)
}
}
}
@@ -45,7 +45,12 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -41,9 +41,6 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
import com.rtbishop.look4sat.core.data.usecase.SaveImage
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
@@ -54,7 +51,6 @@ import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
@@ -66,16 +62,12 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() }
private val remoteSource = provideRemoteSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
@@ -88,42 +80,14 @@ class MainContainer(private val context: Context) : IMainContainer {
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
}
private val _mutualPassData = MutableStateFlow(MutualPassData())
override val mutualPassData: StateFlow<MutualPassData> = _mutualPassData.asStateFlow()
override fun setMutualPassData(data: MutualPassData) {
_mutualPassData.value = data
}
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
// 每次调用返回新实例: 调用方负责 close() 释放 OrtSession, 且 Radar 内嵌
// 面板与独立 CW 页各自持有自己的解码器
override fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder =
com.rtbishop.look4sat.core.data.cw.CwDeepDecoder(context)
override fun provideSaveImage(): ISaveImage = SaveImage(context)
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
override val wavelogQueue: WavelogQueue by lazy {
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
WavelogQueue(object : IWavelogQueueStore {
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
})
}
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
private val lotwRepo: LotwSatellitesRepo by lazy {
LotwSatellitesRepo(context).also { it.restore() }
}
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
override fun provideBluetoothReporter(): IReporter {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val rc = settingsRepo.rcSettings.value
@@ -174,9 +138,17 @@ class MainContainer(private val context: Context) : IMainContainer {
return SensorsRepo(manager, displayManager)
}
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
@@ -187,14 +159,7 @@ class MainContainer(private val context: Context) : IMainContainer {
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(
Dispatchers.IO, context.contentResolver,
OkHttpClient.Builder()
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.build()
)
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
}
private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -210,6 +175,6 @@ class MainContainer(private val context: Context) : IMainContainer {
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(context, manager, appPreferences, appVersionName)
return SettingsRepo(manager, appPreferences, appVersionName)
}
}
@@ -47,7 +47,6 @@ class DatabaseRepo(
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -69,33 +68,20 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls)
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
putAll(Sources.transceiversDataUrls)
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
}.filterValues { it.isNotBlank() }
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
if (successCount == 0) {
throw java.io.IOException("All data sources failed to download")
// parse fetched data concurrently
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty()
}
// parse fetched data concurrently and associate with types
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) ->
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
}
// insert parsed data into the database
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -73,7 +74,7 @@ class SatelliteRepo(
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
modes = settingsRepo.selectedSatModes.value
)
}
}
@@ -95,8 +96,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) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter ->
transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -152,7 +159,7 @@ class SatelliteRepo(
}
}
newPasses.sortBy { it.aosTime }
delay(1000) // Simulate loading time for better UX
delay(1000.milliseconds) // Simulate loading time for better UX
_passes.update { newPasses }
}
_isCalculating.value = false
@@ -170,7 +177,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -236,16 +243,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// refine AOS to ~500ms precision via binary search.
// Elevation is monotonic across the horizon crossing, so binary search
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
while (aosHi - aosLo > 500L) {
val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// refine AOS to ~500ms precision
calendarTimeMillis -= 60L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0)
// Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -259,14 +263,13 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// refine LOS to ~500ms precision via binary search (same monotonic argument)
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
while (losHi - losLo > 500L) {
val mid = (losLo + losHi) / 2
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
}
calendarTimeMillis = losHi
// refine LOS to ~500ms precision
calendarTimeMillis -= 30L * 1000L
do {
calendarTimeMillis += 500L
elevation = sat.getElevation(pos, calendarTimeMillis)
if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0)
// Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -29,7 +29,6 @@ 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.source.Sources
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
@@ -40,7 +39,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class SettingsRepo(
private val context: android.content.Context,
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
@@ -70,16 +68,13 @@ class SettingsRepo(
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStateOfNightMode = "stateOfNightMode"
private val keyHiddenScreens = "hiddenScreens"
private val keyScreenOrder = "screenOrder"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
@@ -91,22 +86,19 @@ class SettingsRepo(
private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val keySubMenuOrder = "subMenuOrder"
private val keyWavelogUrl = "wavelogUrl"
private val keyWavelogApiKey = "wavelogApiKey"
private val keyWavelogStationId = "wavelogStationId"
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val separatorComma = ","
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -114,10 +106,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -126,10 +118,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -144,7 +136,6 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -155,16 +146,13 @@ class SettingsRepo(
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 invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
invertAosTimeWindow
)
}
//endregion
@@ -187,41 +175,23 @@ class SettingsRepo(
return true
}
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
override suspend fun setStationPosition(): Boolean {
// Permission gate: fail fast without location permission (no swallowed exceptions)
if (androidx.core.content.ContextCompat.checkSelfPermission(
context, android.Manifest.permission.ACCESS_FINE_LOCATION
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
println("GPS: no fine location permission")
return false
}
override fun setStationPosition(): Boolean {
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
val signal = android.os.CancellationSignal()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
val executor = java.util.concurrent.Executor { handler.post(it) }
// 15 s timeout
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) { }
}
try {
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
val provider = if (hasGps) providerGps else providerNet
try {
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
} catch (exception: SecurityException) {
handler.removeCallbacksAndMessages(null)
if (cont.isActive) cont.resume(false) { }
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
val location = locationManager.getLastKnownLocation(providerDef)
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
println("Requesting location for $provider provider")
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 {
@@ -240,8 +210,8 @@ class SettingsRepo(
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(5)
val newLon = longitude.round(5)
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -262,25 +232,6 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
val typeString = preferences.getString("type$type", null)
val typeIds = if (typeString.isNullOrBlank()) {
emptyList()
} else {
typeString.split(separatorComma).map { it.toInt() }
}
idsSet.addAll(typeIds)
}
return idsSet.toList()
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
putInt(keyNumberOfSatellites, state.numberOfSatellites)
@@ -383,13 +334,8 @@ class SettingsRepo(
putString(keySstvMode, new.sstvMode)
putLong(keyLowElevation, new.lowElevation.toRawBits())
putLong(keyHighElevation, new.highElevation.toRawBits())
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
putString(keyScreenOrder, new.screenOrder.joinToString(","))
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
putString(keyWavelogUrl, new.wavelogUrl)
putString(keyWavelogApiKey, new.wavelogApiKey)
putString(keyWavelogStationId, new.wavelogStationId)
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -405,15 +351,10 @@ class SettingsRepo(
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
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())),
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
)
//endregion
@@ -431,20 +372,12 @@ class SettingsRepo(
_dataSourcesSettings.value = settings
}
private fun getDataSourcesSettings(): DataSourcesSettings {
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
return DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
tleUrl = tleUrl,
transceiversUrl = txUrl
)
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//endregion
//region # Radio control settings
@@ -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 satellite status page parser (https://amsat.org/status/)
*
* Page structure (static HTML, verified 2026-08):
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
* docTips.show('id') link
* - Report details live in the page's inline JS:
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
*
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
*/
object AmSatParser {
private val STATUS_COLORS = mapOf(
"#648fff" to 0xFF648FFF.toLong(),
"#ffb000" to 0xFFFFB000.toLong(),
"#dc267f" to 0xFFDC267F.toLong(),
"#fe6100" to 0xFFFE6100.toLong()
)
private val GRAY = 0xFFC0C0C0.toLong()
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
private val tipRe = Pattern.compile(
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
)
/** Parse the full page */
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
val reports = parseReports(html)
val statuses = parseStatusTable(html, reports)
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
}
/** Extract all reports (tips.* JS arrays) */
fun parseReports(html: String): Map<String, SatReport> {
val map = mutableMapOf<String, SatReport>()
val m = tipRe.matcher(html)
while (m.find()) {
val id = m.group(1)
val parts = m.group(2).split("<br>")
map[id] = SatReport(
id = id,
statusText = parts.getOrElse(0) { "" }.trim(),
call = parts.getOrElse(1) { "" }.trim(),
grid = parts.getOrElse(2) { "" }.trim(),
dateUtc = parts.getOrElse(3) { "" }.trim(),
timeUtc = parts.getOrElse(4) { "" }.trim()
)
}
return map
}
/** Parse the status table (48 satellite rows) */
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
val result = mutableListOf<SatStatus>()
val tables = extractTables(html)
for (table in tables) {
val rows = rowRe.matcher(table)
val parsed = mutableListOf<SatStatus>()
var rowIndex = 0
var dayHeaders: List<String> = emptyList()
while (rows.find()) {
val rowHtml = rows.group(1)
val cells = cellRe.matcher(rowHtml)
val cellList = mutableListOf<String>()
while (cells.find()) cellList.add(cells.group(1))
if (rowIndex == 0) {
// Header: Name + 6 days (each colspan=12)
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
rowIndex++
continue
}
if (cellList.size < 7) { rowIndex++; continue }
val name = stripHtml(cellList[0])
if (name.isBlank()) { rowIndex++; continue }
val days = mutableListOf<SatDay>()
for (d in 0 until 6) {
val start = 1 + d * 12
val end = start + 12
val slots = (start until end).mapNotNull { i ->
cellList.getOrNull(i)?.let { cell ->
val bg = bgRe.matcher(cell)
val color = if (bg.find()) {
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
} else GRAY
val link = linkRe.matcher(cell)
val ids = mutableListOf<String>()
while (link.find()) ids.add(link.group(1))
val hasReport = ids.isNotEmpty()
val count = if (hasReport) {
stripHtml(cell).trim().toIntOrNull() ?: ids.size
} else 0
SatSlot(
statusColor = if (hasReport) color else GRAY,
count = count,
reportIds = ids
)
}
}
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
}
parsed.add(SatStatus(name, days))
rowIndex++
}
if (parsed.isNotEmpty()) {
result.addAll(parsed)
break
}
}
return result
}
private fun extractTables(html: String): List<String> {
val result = mutableListOf<String>()
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
while (m.find()) result.add(m.group())
return result
}
private fun stripHtml(s: String): String =
s.replace(Regex("<[^>]+>"), "").trim()
}
@@ -24,7 +24,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -43,29 +42,17 @@ class RemoteSource(
}
}
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
try {
val request = Request.Builder()
.url("https://amsat.org/status/")
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat status exception: $exception")
null
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
@@ -76,14 +63,14 @@ class RemoteSource(
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/catalog.php")
.header("User-Agent", "Look4Sat/4.5.5")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat catalog exception: $exception")
println("RemoteSource getAmSatCatalog exception: $exception")
null
}
}
@@ -92,14 +79,14 @@ class RemoteSource(
try {
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
.header("User-Agent", "Look4Sat/4.5.5")
.header("User-Agent", "Look4Sat")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body?.string()
response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource amsat reports exception: $exception")
println("RemoteSource getAmSatReports exception: $exception")
null
}
}
@@ -25,8 +25,4 @@ class ShowToast(private val context: Context) : IShowToast {
override fun invoke(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
override fun invoke(resId: Int) {
invoke(context.getString(resId))
}
}
@@ -1,62 +0,0 @@
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
*/
package com.rtbishop.look4sat.core.data.wavelog
import android.content.Context
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.URL
import java.util.zip.GZIPInputStream
import kotlin.random.Random
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
/** Restore the last downloaded list from SharedPreferences (call at process start) */
override fun restore() {
val saved = prefs.getString(KEY_NAMES, null)
if (!saved.isNullOrBlank()) {
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
}
}
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
try {
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
val raw = conn.getInputStream()
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
val text = reader.use { it.readText() }
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
.map { it.groupValues[1].uppercase() }
.toSet()
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
LotwSatellites.updateNames(names)
prefs.edit()
.putString(KEY_NAMES, names.joinToString(","))
.putLong(KEY_UPDATED, System.currentTimeMillis())
.apply()
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
} catch (e: Exception) {
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
}
}
companion object {
private const val KEY_NAMES = "names"
private const val KEY_UPDATED = "updated"
}
}
@@ -62,7 +62,6 @@ class DatabaseRepoTest {
assertEquals(1, localSource.insertedEntries.size)
assertEquals(25544, localSource.insertedEntries.first().catnum)
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
assertTrue(settingsRepo.databaseState.value.numberOfSatellites > 0)
}
@@ -102,8 +101,6 @@ class DatabaseRepoTest {
repository.updateFromRemote()
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
}
private fun validCsvStream(): InputStream = """
@@ -166,10 +163,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -190,11 +187,9 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
val satelliteTypeIdsByType = mutableMapOf<String, List<Int>>()
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
@@ -204,11 +199,6 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
satelliteTypeIdsByType[type] = ids
}
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
@@ -0,0 +1,173 @@
/*
* 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.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.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
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, "", "", "", false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "")
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
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 updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
}
}
-5
View File
@@ -1,8 +1,3 @@
plugins {
alias(libs.plugins.convention.coreDomainPlugin)
}
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -1,116 +0,0 @@
package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
*/
object AprsPacket {
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
fun passcode(callsign: String): Int {
val s = callsign.split("-")[0].uppercase() + "\u0000"
var hash = 29666 // 0x73E2
var i = 0
while (i <= s.length - 2) {
hash = hash xor (s[i].code * 256 + s[i + 1].code)
i += 2
}
return hash and 0x7FFF
}
/** Login line: user CALL-SSID pass XXXX vers XXXX */
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
val callSsid = formatCallSsid(callsign, ssid)
return "user $callSsid pass $passcode vers $version"
}
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
fun formatCallSsid(callsign: String, ssid: String): String {
if (ssid.isNullOrEmpty()) return callsign
return "$callsign-$ssid"
}
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/** Altitude extension /A=00000 (feet) */
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
}
/** Speed/course extension (knots/degrees) */
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt()
return String.format("/%03d/%03d", bearing.toInt(), knots)
}
}
/**
* APRS position encoding (reverse-ported from ab0oo Position.java).
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
*/
class AprsPosition(
val latitude: Double,
val longitude: Double,
val symbolTable: Char,
val symbolCode: Char,
val positionAmbiguity: Int = 0
) {
/** Uncompressed format (APRS-IS default reporting format) */
fun toUncompressedString(): String {
val lat = getDMS(latitude, true)
val lon = getDMS(longitude, false)
return "$lat$symbolTable$lon$symbolCode"
}
/** Compressed format (base91, standard APRS algorithm) */
fun toCompressedString(): String {
val jRound = round((90.0 - latitude) * 380926.0).toLong()
val j = jRound / 753571 + 33
val j2 = jRound % 753571
val j3 = j2 / 8281 + 33
val j4 = j2 % 8281
val i = (j4 % 91).toInt() + 33
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
val j5 = jRound2 / 753571 + 33
val j6 = jRound2 % 753571
val j7 = 33 + j6 / 8281
val j8 = j6 % 8281
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
j5.toInt().toChar() + j7.toInt().toChar() +
((j8 / 91).toInt() + 33).toChar() +
((j8 % 91).toInt() + 33).toChar() + symbolCode
}
/** Single-axis DMS encoding (hundredths) */
private fun getDMS(value: Double, isLat: Boolean): String {
var iRound = round(value * 6000.0).toInt()
if (iRound < 0) iRound = -iRound
val degrees = iRound / 6000
val minutes = (iRound / 100) % 60
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format("%d . ", minutes / 10)
3 -> String.format("%02d. ", minutes)
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
else -> String.format("%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format("%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format("%03d%s%c", degrees, frac, ew)
}
}
}
@@ -1,61 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Turns the model's `log_probs` output into text.
*
* Mirrors the reference `greedy_ctc_decode`: take the best class per frame,
* drop blanks, and collapse runs of the same label. A blank between two
* identical labels is what keeps a genuine double letter (for example the
* two N's in "5NN") from collapsing into one.
*/
object CwCtcDecoder {
/**
* @param logProbs `[batch, time, class]`; only batch 0 is read.
* @param chars class index to symbol, excluding the blank.
* @param blankIndex the CTC blank class (41 for this model).
*/
fun greedy(
logProbs: Array<Array<FloatArray>>,
chars: List<String>,
blankIndex: Int
): String {
if (logProbs.isEmpty()) return ""
val frames = logProbs[0]
val builder = StringBuilder()
var previous = -1
for (frame in frames) {
var best = 0
for (i in 1 until frame.size) {
if (frame[i] > frame[best]) best = i
}
if (best == blankIndex) {
previous = -1
continue
}
if (best != previous && best < chars.size) {
builder.append(chars[best])
}
previous = best
}
return builder.toString()
}
}
@@ -1,140 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
/**
* Bounded rolling audio buffer that drives periodic full re-decodes.
*
* DeepCW is a whole-segment CTC model. It rewrites earlier output as more
* context arrives — measured on one clip, 6 of 11 progressively longer reads
* revised the prefix (`BM` -> `BG7` -> `BG7NTI` -> `BG7NTA`). Appending only
* the newest fragment therefore leaves those intermediate guesses on screen
* forever; measured character error rate for sliding-window stitching ranged
* from 67% to 294%, against 0% for decoding the whole segment at once.
*
* So we keep a fixed window, re-run the model over all of it every
* [redecodeIntervalMs], and replace the displayed text outright.
*
* Defaults come from measurement: 20 s is the smallest window that reaches
* 0.0% CER (16 s still errs at 5.9%), while inference cost grows
* super-linearly — 60 s of audio needs roughly 13x longer to decode than
* 20 s does, leaving too little real-time headroom.
*/
class CwDeepBuffer(
sampleRate: Int = CwDeepSpectrogram.SAMPLE_RATE,
maxSeconds: Double = DEFAULT_MAX_SECONDS,
private val redecodeIntervalMs: Int = DEFAULT_REDECODE_INTERVAL_MS
) {
companion object {
const val DEFAULT_MAX_SECONDS = 20.0
const val DEFAULT_REDECODE_INTERVAL_MS = 1500
}
/** Maximum number of samples retained. */
val capacity: Int = (sampleRate * maxSeconds).toInt()
private val samplesPerInterval: Int = sampleRate * redecodeIntervalMs / 1000
private val ring = FloatArray(capacity)
private var writeIndex = 0
private var filled = 0
private var sinceLastRedecode = 0
/**
* Samples evicted from the ring once it is full. They are the audio that
* has scrolled out of the 20 s window, and are handed off (via
* [drainOverflow]) so the decoder can archive them into permanent history
* instead of silently dropping the corresponding text. Pre-allocated to
* [capacity]: overflow never exceeds one window before it is drained.
*/
private val overflow = FloatArray(capacity)
private var overflowSize = 0
/** Samples currently buffered, never above [capacity]. */
val size: Int get() = filled
/** Samples currently held in the overflow (awaiting archival). */
val overflowCount: Int get() = overflowSize
/** True once there is enough audio for the spectrogram to yield a frame. */
val hasEnoughAudio: Boolean get() = filled >= CwDeepSpectrogram.FFT_LENGTH
/**
* Append captured audio, overwriting the oldest samples when full.
*
* @return true when [redecodeIntervalMs] of audio has accumulated since
* the last time this returned true, meaning the caller should re-decode.
*/
fun append(chunk: FloatArray): Boolean {
if (chunk.isNotEmpty()) {
// A chunk longer than the window can only contribute its tail.
val start = maxOf(0, chunk.size - capacity)
for (i in start until chunk.size) {
if (filled == capacity) {
// The slot we are about to overwrite holds the oldest
// sample — move it to the overflow for archival.
overflow[overflowSize++] = ring[writeIndex]
}
ring[writeIndex] = chunk[i]
writeIndex = (writeIndex + 1) % capacity
if (filled < capacity) filled++
}
}
sinceLastRedecode += chunk.size
if (sinceLastRedecode >= samplesPerInterval) {
sinceLastRedecode -= samplesPerInterval
return true
}
return false
}
/** Buffered audio in chronological order, as a copy safe to hand off. */
fun snapshot(): FloatArray {
val out = FloatArray(filled)
if (filled == 0) return out
val start = (writeIndex - filled + capacity) % capacity
val firstRun = minOf(filled, capacity - start)
ring.copyInto(out, 0, start, start + firstRun)
if (firstRun < filled) {
ring.copyInto(out, firstRun, 0, filled - firstRun)
}
return out
}
/**
* Return the evicted samples (chronological order) and clear the overflow.
* Safe to call every append; returns an empty array when nothing has been
* evicted yet.
*/
fun drainOverflow(): FloatArray {
if (overflowSize == 0) return FloatArray(0)
val out = overflow.copyOf(overflowSize)
overflowSize = 0
return out
}
/** Drop all audio (including pending overflow) and restart the interval. */
fun reset() {
writeIndex = 0
filled = 0
sinceLastRedecode = 0
overflowSize = 0
ring.fill(0f)
overflow.fill(0f)
}
}
@@ -1,208 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.ln1p
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Audio front-end for the DeepCW model: turns PCM samples into the
* `[time, frequency]` log-magnitude spectrogram the network expects.
*
* Mirrors the upstream Python reference (deepcw-engine
* `examples/python/decode_morse.py`) step for step:
*
* resample -> 3200 Hz, reflect-pad by fft/2, periodic Hann window of 256,
* real FFT, keep bins [32, 97) i.e. 400-1200 Hz, then log1p.
*
* The model's fixed 400-1200 Hz window means pitch detection is built in —
* no spectral peak tracking or squelch gating is needed on our side.
*/
object CwDeepSpectrogram {
/** Model input sample rate, from `model.onnx.json`. */
const val SAMPLE_RATE = 3200
/** FFT window length in samples. */
const val FFT_LENGTH = 256
/** Hop between consecutive frames; 48/3200 = 15.0 ms per frame. */
const val HOP_LENGTH = 48
private const val MIN_FREQ_HZ = 400.0
private const val MAX_FREQ_HZ = 1200.0
/** Number of frequency bins the model expects. */
const val FREQUENCY_BINS = 65
/** Milliseconds of audio represented by one output frame. */
const val MS_PER_FRAME = 1000.0 * HOP_LENGTH / SAMPLE_RATE
private val hannWindow: FloatArray = FloatArray(FFT_LENGTH) { i ->
// numpy: np.hanning(N + 1)[:-1] — the periodic (not symmetric) variant.
(0.5 - 0.5 * cos(2.0 * PI * i / FFT_LENGTH)).toFloat()
}
/**
* Inclusive-exclusive bin range covering [minHz, maxHz].
* Returns `start to stop`, matching the reference's `frequency_bin_range`.
*/
fun frequencyBinRange(
sampleRate: Int,
fftLength: Int,
minHz: Double,
maxHz: Double
): Pair<Int, Int> {
val binHz = sampleRate.toDouble() / fftLength
val start = ceil(minHz / binHz).toInt()
val stop = floor(maxHz / binHz).toInt() + 1
return start to stop
}
/**
* Linear-interpolation resampler. Deliberately dependency-light and
* identical to the reference implementation so spectrograms match.
*/
fun resampleLinear(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
if (sourceRate == targetRate || audio.isEmpty()) return audio
val targetLength = (audio.size.toDouble() * targetRate / sourceRate).roundToInt()
val out = FloatArray(targetLength)
val ratio = sourceRate.toDouble() / targetRate
for (i in 0 until targetLength) {
val position = i * ratio
val left = floor(position).toInt()
val right = minOf(left + 1, audio.size - 1)
val fraction = (position - left).toFloat()
out[i] = audio[left] * (1f - fraction) + audio[right] * fraction
}
return out
}
/**
* Build the log-magnitude spectrogram. Input must already be at
* [SAMPLE_RATE]; use [resampleLinear] first when it is not.
*
* @return `[frames][FREQUENCY_BINS]` values, all non-negative.
*/
fun compute(audio: FloatArray): Array<FloatArray> {
require(audio.size >= FFT_LENGTH) {
"audio is too short for fftLength=$FFT_LENGTH, got ${audio.size}"
}
val (startBin, stopBin) = frequencyBinRange(
SAMPLE_RATE, FFT_LENGTH, MIN_FREQ_HZ, MAX_FREQ_HZ
)
val bins = stopBin - startBin
require(bins == FREQUENCY_BINS) {
"expected $FREQUENCY_BINS bins, computed $bins"
}
val padded = reflectPad(audio, FFT_LENGTH / 2)
val frames = 1 + (padded.size - FFT_LENGTH) / HOP_LENGTH
val result = Array(frames) { FloatArray(bins) }
val real = FloatArray(FFT_LENGTH)
val imag = FloatArray(FFT_LENGTH)
for (frame in 0 until frames) {
val offset = frame * HOP_LENGTH
for (i in 0 until FFT_LENGTH) {
real[i] = padded[offset + i] * hannWindow[i]
imag[i] = 0f
}
fftInPlace(real, imag)
val row = result[frame]
for (bin in startBin until stopBin) {
val magnitude = sqrt(real[bin] * real[bin] + imag[bin] * imag[bin])
row[bin - startBin] = ln1p(magnitude.toDouble()).toFloat()
}
}
return result
}
/**
* numpy `mode="reflect"`: mirrors around the edge samples without
* repeating them, so [1,2,3] padded by 2 becomes [3,2,1,2,3,2,1].
*/
private fun reflectPad(audio: FloatArray, pad: Int): FloatArray {
if (pad == 0) return audio
val out = FloatArray(audio.size + 2 * pad)
for (i in 0 until pad) out[i] = audio[pad - i]
audio.copyInto(out, pad)
val last = audio.size - 1
for (i in 0 until pad) out[pad + audio.size + i] = audio[last - 1 - i]
return out
}
/**
* Iterative radix-2 Cooley-Tukey FFT. [FFT_LENGTH] is a power of two, so
* no padding case is needed. Only the first half of the output is read by
* [compute], which is the real-input equivalent of numpy's `rfft`.
*/
private fun fftInPlace(real: FloatArray, imag: FloatArray) {
val n = real.size
// Bit-reversal permutation.
var j = 0
for (i in 1 until n) {
var bit = n shr 1
while (j and bit != 0) {
j = j xor bit
bit = bit shr 1
}
j = j or bit
if (i < j) {
var tmp = real[i]; real[i] = real[j]; real[j] = tmp
tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp
}
}
var length = 2
while (length <= n) {
val angle = -2.0 * PI / length
val wReal = cos(angle).toFloat()
val wImag = kotlin.math.sin(angle).toFloat()
var i = 0
while (i < n) {
var curReal = 1f
var curImag = 0f
for (k in 0 until length / 2) {
val evenReal = real[i + k]
val evenImag = imag[i + k]
val oddReal = real[i + k + length / 2]
val oddImag = imag[i + k + length / 2]
val mulReal = oddReal * curReal - oddImag * curImag
val mulImag = oddReal * curImag + oddImag * curReal
real[i + k] = evenReal + mulReal
imag[i + k] = evenImag + mulImag
real[i + k + length / 2] = evenReal - mulReal
imag[i + k + length / 2] = evenImag - mulImag
val nextReal = curReal * wReal - curImag * wImag
curImag = curReal * wImag + curImag * wReal
curReal = nextReal
}
i += length
}
length = length shl 1
}
}
}
@@ -1,66 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import kotlinx.coroutines.flow.StateFlow
/**
* A CW (Morse code) decoder fed with microphone PCM.
*
* Implementations live outside `core:domain` when they need platform APIs;
* this contract stays pure Kotlin so the UI can depend on it directly.
*/
interface ICwDecoder {
/**
* Decoded text for the *current* window.
*
* Note this is **replace** semantics, not append: a whole-segment model
* revises earlier characters as more audio arrives, so consumers must show
* the current value rather than accumulating emissions.
*/
val decodedText: StateFlow<String>
/**
* Permanent transcript of everything that has scrolled out of the live
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
* what the user reads back after a signal has passed.
*/
val historyText: StateFlow<String>
/** Detected tone frequency in Hz, or null before a tone is found. */
val estimatedPitch: StateFlow<Float?>
/** Relative signal strength in 0..1 for level meters. */
val signalStrength: StateFlow<Float>
/** Most recent inference duration in milliseconds, for diagnostics. */
val lastInferenceMs: StateFlow<Int>
/** Non-null when the decoder cannot run, for example the model failed to load. */
val errorMessage: StateFlow<String?>
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
/** Clear decoded text and buffered audio. */
fun reset()
/** Release native resources. Must be called when the decoder goes away. */
fun close()
}
@@ -29,8 +29,7 @@ data class PassesSettings(
val minElevation: Double,
val aosStartMinute: Int = 0,
val aosEndMinute: Int = 23 * 60 + 59,
val invertAosTimeWindow: Boolean = false,
val selectedModes: List<String>
val invertAosTimeWindow: Boolean = false
)
data class RCSettings(
@@ -63,17 +62,8 @@ data class OtherSettings(
val sstvMode: String = "Auto",
val lowElevation: Double = 15.0,
val highElevation: Double = 45.0,
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
val hiddenScreens: List<String> = emptyList(),
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
val screenOrder: List<String> = emptyList(),
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
val subMenuOrder: List<String> = emptyList(),
// WaveLog logging (4.5.2): server config
val wavelogUrl: String = "",
val wavelogApiKey: String = "",
val wavelogStationId: String = "",
val wavelogAutoUpload: Boolean = false
val radarCompassOffset: Float = 0f,
val radarCompassOffsetElev: Float = 0f
)
data class DataSourcesSettings(
@@ -1,71 +0,0 @@
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
* never built into the app.
*/
package com.rtbishop.look4sat.core.domain.qrz
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.URL
object QrzGridClient {
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
fun parseCookies(raw: String): String {
val text = raw.trim()
if (text.isEmpty()) return ""
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
if (text.startsWith("[")) {
return try {
val arr = org.json.JSONArray(text)
val parts = mutableListOf<String>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val name = o.optString("name")
val value = o.optString("value")
if (name.isNotBlank()) parts.add("$name=$value")
}
parts.joinToString("; ")
} catch (_: Exception) { text }
}
return text
}
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
pattern.find(html)?.groupValues?.get(1)?.trim()
} catch (_: Exception) { null }
}
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
try {
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
val conn = url.openConnection()
conn.connectTimeout = 10_000
conn.readTimeout = 20_000
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
conn.setRequestProperty("Cookie", cookieHeader)
val html = conn.getInputStream().bufferedReader().use { it.readText() }
// Detail table Grid Square row (verified format)
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
.find(html)
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
} catch (_: Exception) { null }
}
}
@@ -16,13 +16,12 @@
* 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.SatRadio
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
import com.rtbishop.look4sat.core.domain.usecase.ISaveImage
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
interface IMainContainer {
val appScope: CoroutineScope
@@ -30,10 +29,8 @@ interface IMainContainer {
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
val amSatRepo: IAmSatRepository
val amSatRepo: IAmSatRepository
val radioTrackingService: IRadioTrackingService
val mutualPassData: StateFlow<MutualPassData>
fun setMutualPassData(data: MutualPassData)
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporter
@@ -42,34 +39,10 @@ interface IMainContainer {
fun provideTxRadioController(): IRadioController
fun provideRxRadioController(): IRadioController
fun provideAudioCapture(): IAudioCapture
fun provideCwDecoder(): com.rtbishop.look4sat.core.domain.cw.ICwDecoder
fun provideSaveImage(): ISaveImage
// WaveLog logging (4.5.2)
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
fun providePairedBluetoothDevices(): List<Pair<String, String>>
}
data class MutualPassData(
val samples: List<Pair<Long, Pair<Double, Double>>> = emptyList(),
val trackSamples: List<TrackSampleData> = emptyList(),
val startTime: Long = 0L,
val endTime: Long = 0L,
val maxElev: Double = 10.0,
val labelA: String = "你",
val labelB: String = "友台"
)
/** Minimal track sample for cross-module sharing (angles in degrees). */
data class TrackSampleData(
val time: Long = 0L,
val azimuthA: Double,
val elevationA: Double,
val azimuthB: Double,
val elevationB: Double
)
interface IContainerProvider {
fun getMainContainer(): IMainContainer
}
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
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. */
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. */
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -21,10 +21,10 @@ import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
fun getCurrentModes(): List<String>
fun getModesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>)
suspend fun setModes(modes: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
@@ -32,9 +32,9 @@ interface ISettingsRepo {
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
val selectedSatModes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
fun setSelectedSatModes(modes: List<String>)
//endregion
//region # Passes filter settings
@@ -45,15 +45,12 @@ interface ISettingsRepo {
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
suspend fun setStationPosition(): Boolean
fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState)
//endregion
@@ -22,13 +22,6 @@ import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
/** Fetch AMSAT status page HTML (with UA; null = failure) */
suspend fun getStatusHtml(): String?
/** Fetch AMSAT API catalog (JSON string; null on failure) */
suspend fun getAmSatCatalog(): String?
/** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */
suspend fun getAmSatReports(hours: Int, limit: Int): String?
}
@@ -18,41 +18,26 @@
package com.rtbishop.look4sat.core.domain.source
object Sources {
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"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",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&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",
"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",
"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",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"CelesTrak" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"AMSAT" to "https://amsat.org/tle/current/nasabare.txt",
"McCants Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants Integrated" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
"ARISS" to "https://live.ariss.org/iss.txt",
"Other" to "" // key for sats filter
)
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
"BPSK PMT-A3", "CERTO", "CW", "DATV", "DBPSK", "DOKA", "DPSK", "DQPSK", "DSB", "DSTAR",
"DUV", "DVB-S2", "FFSK", "FM", "FMN", "FSK", "FSK AX.100 Mode 5", "FSK AX.100 Mode 6",
"FSK AX.25 G3RUH", "FT8", "GENESIS FSK", "GFSK", "GFSK Pkst", "GFSK Rktr", "GFSK/BPSK",
"GMSK", "GMSK USP", "HRPT", "LoRa", "LRPT", "LSB", "MFSK", "MSK", "MSK AX.100 Mode 5",
"MSK AX.100 Mode 6", "OFDM", "OQPSK", "PPM", "PSK", "PSK31", "PSK63", "QPSK", "QPSK31",
"QPSK63", "SIDLOC", "SQPSK", "SSDV", "SSTV", "UNKNOWN", "USB", "WSJT"
)
}
@@ -19,7 +19,4 @@ package com.rtbishop.look4sat.core.domain.usecase
interface IShowToast {
operator fun invoke(message: String)
/** Show by resource ID (four-language text) */
operator fun invoke(resId: Int)
}
@@ -91,7 +91,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
val line1 = tle[1]
val line2 = tle[2]
OrbitalData(
name = tle[0].trim(),
name = tle[0].trim().removePrefix("0 "),
epoch = line1.substring(18, 32).toDouble(),
meanmo = line2.substring(52, 63).toDouble(),
eccn = line2.substring(26, 33).toDouble() / 1e7,
@@ -18,12 +18,10 @@ import java.util.Locale
*
* For a linear (passband) transponder, uplink and downlink frequencies are
* related by a fixed passband offset. When the satellite moves, both are
* Doppler-shifted. Given one, we compute the other:
* Doppler-shifted. Given one, this computes the other:
*
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
*
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
*/
object DopplerFrequencyCalculator {
@@ -104,8 +102,8 @@ object DopplerFrequencyCalculator {
* True for the radio entry that should drive the standalone Calculator page.
*
* A frequency range alone is not enough: some non-user-facing or drifting data entries
* can also have low/high frequencies. The calculator is meant for the named linear
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
* can also have low/high frequencies. The calculator is meant for named linear
* transponders, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
*/
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
@@ -19,78 +19,31 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
* The returned position is the center of the finest cell encoded by the locator:
* - 6 char: 5' lon x 2.5' lat cell center
* - 8 char: 30" lon x 15" lat cell center
* - 10 char: 1.25" lon x 0.625" lat cell center
*/
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.trim().uppercase()
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].code - 65) * 10
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
var longitude = lonFirst + lonSecond + lonThird - 180
var latitude = latFirst + latSecond + latThird - 90
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
if (trimmedQth.length >= 8) {
longitude += trimmedQth[6].toString().toInt() / 120.0
latitude += trimmedQth[7].toString().toInt() / 240.0
}
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
if (trimmedQth.length >= 10) {
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
}
// Offset to the center of the finest encoded cell
when (trimmedQth.length) {
8 -> {
longitude += 1.0 / 240.0
latitude += 1.0 / 480.0
}
10 -> {
longitude += 1.0 / 5760.0
latitude += 1.0 / 11520.0
}
else -> {
longitude += 1.0 / 24.0
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(6), longitude.round(6))
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
/**
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
* matching common 8-char grid square tools. Pass precision = 6 for the classic
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
fun positionToQth(latitude: Double, longitude: Double): String? {
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 lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude % 20) / 2).toInt()
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
if (precision < 8) return qth
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
val qth8 = "$qth$lonFourth$latFourth"
if (precision < 10) return qth8
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
return "$qth8$lonFifth$latFifth"
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
@@ -98,49 +51,5 @@ private fun isValidPosition(lat: Double, lon: Double): 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())
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
}
/**
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
* A 4-char input is returned as-is when valid.
*/
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 Locator 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()
@@ -1,15 +0,0 @@
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
*/
package com.rtbishop.look4sat.core.domain.wavelog
interface ILotwSatellitesRepo {
sealed class RefreshResult {
data class Ok(val count: Int) : RefreshResult()
data class Error(val message: String) : RefreshResult()
}
fun restore()
suspend fun refresh(): RefreshResult
}
@@ -1,22 +0,0 @@
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
*/
package com.rtbishop.look4sat.core.domain.wavelog
object LotwSatellites {
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
@Volatile
private var dynamicNames: Set<String> = emptySet()
/** Effective satellite names: static embedded set union runtime updates */
val names: Set<String>
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
fun updateNames(newNames: Set<String>) {
if (newNames.isNotEmpty()) dynamicNames = newNames
}
}
@@ -1,279 +0,0 @@
/*
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
*
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
* Strategy: try v2 first, auto-fallback to v1 on 404.
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
val id: Int,
val name: String,
val callsign: String,
val gridsquare: String
)
sealed class WavelogResult {
data class Success(val message: String) : WavelogResult()
data class Failure(val message: String) : WavelogResult()
}
object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
return u
}
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// v1 attempt without index.php
val body = JSONObject().apply {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
}
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
if (code in 200..299) {
return@withContext try {
val obj = JSONObject(resp)
val data = obj.optJSONObject("data") ?: obj
val station = WavelogStation(
id = data.optInt("id"),
name = data.optString("name"),
callsign = data.optString("callsign"),
gridsquare = data.optString("gridsquare")
)
WavelogResult.Success(JSONObject().apply {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
} catch (e: Exception) {
WavelogResult.Failure("解析失败: ${e.message}")
}
}
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
}
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
fun normalizeSatName(raw: String): String {
val main = raw.substringBefore('(').trim()
.ifBlank { raw.trim() }
.uppercase(Locale.ENGLISH)
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
val commonName = mapOf(
"ZARYA" to "ARISS",
"ARISS" to "ARISS",
"FUNCUBE-1" to "AO-73",
"DIWATA-2B" to "PO-101",
"SAUDISAT-1C" to "SO-50",
"SAUDISAT 1C" to "SO-50",
"DIWATA-2A" to "PO-101"
)
val candidate = commonName[main] ?: main
return if (candidate in LotwSatellites.names) candidate else main
}
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
url: String,
apiKey: String,
stationProfileId: String,
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName)
// v2: POST /index.php/api/v2/qso (JSON fields)
val v2Body = JSONObject().apply {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", "SAT")
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
put("gridsquare", gridsquare)
put("rst_sent", "59")
put("rst_rcvd", "59")
put("sat_name", satName)
}
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
put("key", apiKey)
put("station_profile_id", stationProfileId)
put("type", "adif")
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
// v1 without index.php
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
}
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
return "<$name:$bytes>$value"
}
return buildString {
append(field("call", qso.call))
append(field("band", "SAT"))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
}
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
append(field("rst_sent", "59"))
append(field("rst_rcvd", "59"))
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
if (satName.isNotBlank()) {
append(field("sat_name", satName))
append(field("prop_mode", "SAT"))
}
append("<eor>")
}
}
/** Generic HTTP request (returns code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
}
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
} catch (_: Exception) {
body.take(120)
}
}
private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
}
private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
}
}
@@ -1,108 +0,0 @@
/*
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import org.json.JSONArray
import org.json.JSONObject
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
WavelogQso(
id = o.getString("id"),
timeUtcMs = o.getLong("timeUtcMs"),
call = o.optString("call"),
mode = o.optString("mode"),
freqTxHz = o.optLong("freqTxHz"),
freqRxHz = o.optLong("freqRxHz"),
satName = o.optString("satName"),
sessionId = o.optString("sessionId"),
gridsquare = o.optString("gridsquare"),
uploaded = o.optBoolean("uploaded", false)
)
}
} catch (_: Exception) { emptyList() }
}
@Synchronized
fun add(qso: WavelogQso) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
@Synchronized
fun remove(id: String) {
save(all().filter { it.id != id })
}
@Synchronized
fun removeAll(ids: Set<String>) {
save(all().filter { it.id !in ids })
}
/** Mark as uploaded (kept in the queue; checkmark in the table) */
@Synchronized
fun markUploaded(id: String) {
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
@Synchronized
fun removeUploaded() {
save(all().filter { !it.uploaded })
}
private fun save(list: List<WavelogQso>) {
val arr = JSONArray()
list.forEach { q ->
arr.put(JSONObject().apply {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
store.save(arr.toString())
}
}
@@ -1,92 +0,0 @@
/*
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
*
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
* -> POST /api/v2/qso -> success removes from the queue.
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
* retrying the batch with force=true once confirmed.
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
data class Done(
val successCount: Int,
val failedCount: Int,
val message: String,
val firstError: String = ""
) : UploadOutcome()
}
class WavelogUploader(
private val settingsRepo: ISettingsRepo,
private val queue: WavelogQueue
) {
// Station grid cache (refreshed before each upload; stale value kept on failure)
private var cachedStationGrid: String? = null
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
val settings = settingsRepo.otherSettings.value
val url = settings.wavelogUrl
val apiKey = settings.wavelogApiKey
val stationId = settings.wavelogStationId
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
}
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
if (stationGrid.isNullOrBlank()) {
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
}
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
if (!force) {
val userGrid = userQthGrid()
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
}
}
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
val entries = queue.all()
var ok = 0
var fail = 0
var firstError = ""
for (qso in entries) {
if (qso.uploaded) { ok++; continue }
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
if (result is WavelogResult.Success) {
ok++
queue.markUploaded(qso.id)
} else {
fail++
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
}
}
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
return UploadOutcome.Done(ok, fail, message, firstError)
}
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) {
return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid }
}
return cachedStationGrid
}
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
private fun userQthGrid(): String? {
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
}
}
@@ -3,7 +3,11 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DopplerFrequencyCalculatorTest {
@@ -79,8 +83,6 @@ class DopplerFrequencyCalculatorTest {
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
assertTrue(uplink!! > 0)
// With zero Doppler, result equals mapDownlinkToUplink output
assertEquals(145_200_000L, uplink)
}
@@ -95,24 +97,23 @@ class DopplerFrequencyCalculatorTest {
@Test
fun computeUplinkFromDownlink_withDoppler_positiveRangeRate() {
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate
// Satellite receding (positive range rate) → ground must transmit higher freq to compensate.
val xpdr = linearTransponder()
val orbitalPos = pos(7.0) // ~7 km/s receding
val orbitalPos = pos(7.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Uplink freq should be Doppler shifted UP (compensating for receding)
assertTrue(uplink!! > 145_200_000L)
}
@Test
fun computeUplinkFromDownlink_fm_transponder_returnsNull() {
fun computeUplinkFromDownlink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_600_000L, fmTransponder(), orbitalPos)
assertNull(result)
}
@Test
fun computeDownlinkFromUplink_fm_transponder_returnsNull() {
fun computeDownlinkFromUplink_fmTransponder_returnsNull() {
val orbitalPos = pos()
val result = DopplerFrequencyCalculator.computeDownlinkFromUplink(145_900_000L, fmTransponder(), orbitalPos)
assertNull(result)
@@ -171,13 +172,11 @@ class DopplerFrequencyCalculatorTest {
val orbitalPos = pos(0.0)
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlink(435_200_000L, xpdr, orbitalPos)
assertNotNull(uplink)
// Inverted: offset from high end → maps to high end of uplink
assertEquals(145_300_000L, uplink)
}
@Test
fun computeUplinkFromDownlink_roundTrip() {
// downlink → uplink → downlink should round-trip
val xpdr = linearTransponder()
val orbitalPos = pos(3.5)
val originalDownlink = 435_250_000L
@@ -185,7 +184,6 @@ class DopplerFrequencyCalculatorTest {
assertNotNull(uplink)
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
// Doppler round-trip: small residual due to freq-dependent Doppler
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
}
@@ -18,9 +18,7 @@
package com.rtbishop.look4sat.core.domain
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.qthToSquare
import org.junit.Test
class QthConverterTest {
@@ -28,36 +26,21 @@ class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
}
@Test
@@ -65,61 +48,4 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.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 Locator 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!!") == "----")
}
}
@@ -1,90 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Greedy CTC collapse, matching the reference implementation's
* `greedy_ctc_decode`: drop blanks, then drop runs of the same label.
*
* Alphabet from `model.onnx.json` — 41 symbols plus blank at index 41.
*/
class CwCtcDecoderTest {
private val chars = listOf(
",", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "?",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", " "
)
private val blank = 41
/** Build a `[1, T, 42]` log-prob tensor whose argmax follows [path]. */
private fun logits(path: IntArray): Array<Array<FloatArray>> {
val frames = Array(path.size) { t ->
FloatArray(42) { -10f }.also { it[path[t]] = 0f }
}
return arrayOf(frames)
}
@Test
fun alphabetSizeMatchesModelMetadata() {
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
}
@Test
fun greedy_dropsRunsOfTheSameLabel() {
assertEquals("A", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, 14)), chars, blank))
}
@Test
fun greedy_keepsRepeatsSeparatedByBlank() {
// A A <blank> A collapses to "AA": the blank breaks the run.
assertEquals("AA", CwCtcDecoder.greedy(logits(intArrayOf(14, 14, blank, 14)), chars, blank))
}
@Test
fun greedy_allBlanksYieldEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf(blank, blank, blank)), chars, blank))
}
@Test
fun greedy_emptyInputYieldsEmptyString() {
assertEquals("", CwCtcDecoder.greedy(logits(intArrayOf()), chars, blank))
}
@Test
fun greedy_decodesCallsignWithSpaceAndDigits() {
// "CQ BG7" — C=16 Q=30 space=40 B=15 G=20 7=10
val path = intArrayOf(
blank, 16, 16, blank, 30, blank, 40,
15, blank, 20, blank, 10, blank
)
assertEquals("CQ BG7", CwCtcDecoder.greedy(logits(path), chars, blank))
}
@Test
fun greedy_picksHighestScoringClassPerFrame() {
// Frame favours S (32) over T (33); only S must survive.
val frame = FloatArray(42) { -10f }
frame[33] = -1f
frame[32] = -0.1f
assertEquals("S", CwCtcDecoder.greedy(arrayOf(arrayOf(frame)), chars, blank))
}
}
@@ -1,152 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rolling audio buffer feeding DeepCW.
*
* DeepCW is a whole-segment CTC model, not a sample-by-sample decoder: it
* rewrites earlier output whenever more context arrives, so incremental
* stitching is impossible. Instead we keep a bounded window and re-decode all
* of it periodically, replacing the displayed text.
*/
class CwDeepBufferTest {
@Test
fun capacityIsCappedAtMaxSeconds() {
val buffer = CwDeepBuffer(sampleRate = 3200, maxSeconds = 20.0)
repeat(30) { buffer.append(FloatArray(3200)) }
assertEquals(3200 * 20, buffer.size)
}
@Test
fun oldestSamplesAreDiscardedFirst() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f))
buffer.append(floatArrayOf(4f, 5f))
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
}
@Test
fun snapshotIsChronologicalAfterWrapAround() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun appendLargerThanCapacityKeepsOnlyTheTail() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
assertEquals(4, buffer.size)
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
}
@Test
fun redecodeIsSignalledOncePerInterval() {
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
}
@Test
fun redecodeIntervalDoesNotDriftOverManyChunks() {
// 100 ms chunks, as AudioCapture emits them: exactly 15 chunks per
// 1.5 s interval, so 150 chunks must fire exactly 10 times.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
var fired = 0
repeat(150) { if (buffer.append(FloatArray(320))) fired++ }
assertEquals(10, fired)
}
@Test
fun snapshotDoesNotAliasInternalStorage() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.snapshot()[0] = 99f
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
}
@Test
fun resetClearsSamplesAndIntervalCounter() {
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
buffer.append(FloatArray(3200))
buffer.reset()
assertEquals(0, buffer.size)
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
}
@Test
fun hasEnoughAudioTracksTheModelMinimum() {
// compute() needs at least FFT_LENGTH samples to produce one frame.
val buffer = CwDeepBuffer(3200, 20.0)
buffer.append(FloatArray(100))
assertFalse(buffer.hasEnoughAudio)
buffer.append(FloatArray(200))
assertTrue(buffer.hasEnoughAudio)
}
@Test
fun defaultsMatchTheMeasuredOptimum() {
// 20s / 1.5s were chosen from measurements: 20s is the smallest window
// reaching 0.0% CER, and keeps inference well inside real time.
val buffer = CwDeepBuffer()
assertEquals(CwDeepSpectrogram.SAMPLE_RATE * 20, buffer.capacity)
}
@Test
fun overflowCollectsEvictedSamplesInOrder() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
}
@Test
fun drainOverflowClearsItself() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.drainOverflow()
assertEquals(0, buffer.overflowCount)
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
}
@Test
fun resetClearsOverflow() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.append(floatArrayOf(5f))
assertEquals(1, buffer.overflowCount)
buffer.reset()
assertEquals(0, buffer.overflowCount)
}
}
@@ -1,130 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Pins the Kotlin front-end to the upstream Python reference implementation.
*
* `golden_spec.txt` was produced by deepcw-engine's own preprocessing code
* (numpy reflect padding, `np.hanning(N+1)[:-1]`, `np.fft.rfft`, `log1p`) over
* the audio [generateTestAudio] builds. Both sides synthesise the audio from
* the same deterministic formula, so only the spectrogram needs pinning.
*
* A mismatch here means the model would receive subtly wrong input and emit
* plausible-looking garbage, which is very hard to diagnose downstream — so
* this test guards the whole pipeline.
*
* Regenerate with `scripts/deepcw_gen_golden.py` from the skill library if the
* model metadata ever changes.
*/
class CwDeepGoldenVectorTest {
private companion object {
const val SRC_RATE = 8000
const val TONE_HZ = 700.0
const val AMPLITUDE = 0.6
const val DOT_SAMPLES = 480 // 20 WPM at 8000 Hz: 1.2/20*8000
const val PATTERN = "-.-." // the letter C
const val TOLERANCE = 1e-4f
}
/** Square-keyed 700 Hz tone: 4 dots of silence, "C", 4 dots of silence. */
private fun generateTestAudio(): FloatArray {
val keying = ArrayList<Int>()
repeat(4 * DOT_SAMPLES) { keying.add(0) }
for ((i, element) in PATTERN.withIndex()) {
val length = if (element == '-') 3 * DOT_SAMPLES else DOT_SAMPLES
repeat(length) { keying.add(1) }
if (i < PATTERN.length - 1) repeat(DOT_SAMPLES) { keying.add(0) }
}
repeat(4 * DOT_SAMPLES) { keying.add(0) }
return FloatArray(keying.size) { i ->
if (keying[i] == 1) {
(AMPLITUDE * sin(2.0 * PI * TONE_HZ * i / SRC_RATE)).toFloat()
} else {
0f
}
}
}
private fun readGoldenSpectrogram(): Array<FloatArray> {
val stream = javaClass.classLoader?.getResourceAsStream("cw/golden_spec.txt")
?: throw IllegalStateException("cw/golden_spec.txt missing from test resources")
stream.bufferedReader().use { reader ->
val (frames, bins) = reader.readLine().trim().split(" ").map(String::toInt)
return Array(frames) {
val row = reader.readLine().trim().split(" ")
require(row.size == bins) { "expected $bins values, got ${row.size}" }
FloatArray(bins) { i -> row[i].toFloat() }
}
}
}
@Test
fun spectrogramMatchesPythonReferenceFrameByFrame() {
val expected = readGoldenSpectrogram()
val audio = CwDeepSpectrogram.resampleLinear(
generateTestAudio(), SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
val actual = CwDeepSpectrogram.compute(audio)
assertEquals("frame count", expected.size, actual.size)
assertEquals("bin count", expected[0].size, actual[0].size)
var worstDelta = 0f
var worstAt = ""
for (t in expected.indices) {
for (f in expected[t].indices) {
val delta = abs(expected[t][f] - actual[t][f])
if (delta > worstDelta) {
worstDelta = delta
worstAt = "frame $t bin $f: expected ${expected[t][f]}, got ${actual[t][f]}"
}
}
}
assertTrue(
"front-end diverges from the Python reference — worst delta $worstDelta at $worstAt",
worstDelta <= TOLERANCE
)
}
@Test
fun resampledLengthMatchesReference() {
val audio = generateTestAudio()
assertEquals("source audio length", 9120, audio.size)
val resampled = CwDeepSpectrogram.resampleLinear(
audio, SRC_RATE, CwDeepSpectrogram.SAMPLE_RATE
)
assertEquals("resampled length", 3648, resampled.size)
}
@Test
fun goldenVectorHasExpectedShape() {
val golden = readGoldenSpectrogram()
assertEquals("frames", 77, golden.size)
assertEquals("bins", CwDeepSpectrogram.FREQUENCY_BINS, golden[0].size)
}
}
@@ -1,110 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
/**
* Verifies the DeepCW front-end against the upstream Python reference
* implementation (deepcw-engine examples/python/decode_morse.py).
*
* Model metadata: sampleRate 3200, fftLength 256, hopLength 48,
* 400-1200 Hz -> 65 bins, log1p normalization.
*/
class CwDeepSpectrogramTest {
@Test
fun frequencyBinRange_matchesModelMetadata() {
// binHz = 3200/256 = 12.5; start = ceil(400/12.5) = 32; stop = floor(1200/12.5)+1 = 97
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
assertEquals(32, start)
assertEquals(97, stop)
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
}
@Test
fun compute_producesTimeBy65Matrix() {
// 1 second at 3200 Hz. Reflect padding adds fft/2 on both sides,
// so frames = 1 + (3200 + 256 - 256)/48 = 1 + 66 = 67
val spec = CwDeepSpectrogram.compute(FloatArray(3200))
assertEquals(67, spec.size)
assertEquals(65, spec[0].size)
}
@Test
fun compute_toneLandsInExpectedBin() {
// 700 Hz -> absolute bin 700/12.5 = 56 -> relative index 56 - 32 = 24
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
}
@Test
fun compute_appliesLog1pSoValuesAreNonNegative() {
val audio = FloatArray(3200) { (0.6 * sin(2.0 * PI * 700.0 * it / 3200.0)).toFloat() }
val spec = CwDeepSpectrogram.compute(audio)
for (frame in spec) {
for (v in frame) {
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
}
}
}
@Test
fun resampleLinear_convertsRateAndLength() {
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(8000), 8000, 3200).size)
assertEquals(3200, CwDeepSpectrogram.resampleLinear(FloatArray(44100), 44100, 3200).size)
}
@Test
fun resampleLinear_sameRateIsIdentity() {
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
assertEquals(3, out.size)
assertEquals(0.2f, out[1], 1e-6f)
}
@Test
fun resampleLinear_preservesToneFrequency() {
// A 700 Hz tone sampled at 8000 Hz must still peak at bin 24 after
// resampling to 3200 Hz — this is the path real microphone audio takes.
val at8k = FloatArray(8000) { (0.6 * sin(2.0 * PI * 700.0 * it / 8000.0)).toFloat() }
val at3200 = CwDeepSpectrogram.resampleLinear(at8k, 8000, 3200)
val spec = CwDeepSpectrogram.compute(at3200)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
}
@Test
fun compute_rejectsAudioShorterThanFftLength() {
try {
CwDeepSpectrogram.compute(FloatArray(100))
throw AssertionError("expected an exception for audio shorter than fftLength")
} catch (expected: IllegalArgumentException) {
// desired path
}
}
}
@@ -1,78 +0,0 @@
77 65
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
1.564492e-07 1.122884e-07 8.888702e-08 1.762498e-07 8.600421e-09 1.938033e-07 2.387622e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498516e-08 5.176826e-08 1.897711e-07 8.593484e-08 4.070753e-08 7.40209e-08 2.278317e-07 8.807671e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285518e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456021e-08 5.034151e-08 2.597979e-07 1.838301e-07 6.343754e-08 8.150528e-08 5.003271e-09 9.525829e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.698739e-07 5.310058e-08 4.452434e-08 4.024338e-09 5.150024e-08 2.914611e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450444e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353517e-08 7.878904e-08 3.480655e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762498e-07 8.600284e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498505e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716481e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456018e-08 5.034165e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003333e-09 9.525822e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420322e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.0244e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762499e-07 8.600289e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176823e-08 1.897709e-07 8.593484e-08 4.070745e-08 7.402096e-08 2.278317e-07 8.807665e-08 9.716482e-08 1.372644e-07 1.501703e-07 2.285522e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456017e-08 5.034164e-08 2.597979e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003331e-09 9.525821e-08 2.182818e-07 1.843609e-07 1.385006e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590095e-07 3.480243e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024398e-09 5.150032e-08 2.914612e-08 1.276426e-07 1.826462e-07 3.983408e-07 5.450447e-08 2.266521e-07 1.942694e-07 7.255434e-08 1.167993e-07 1.510637e-07 6.353518e-08 7.878896e-08 3.480649e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888702e-08 1.762499e-07 8.600421e-09 1.938034e-07 2.387622e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498516e-08 5.176833e-08 1.897711e-07 8.593477e-08 4.070753e-08 7.402097e-08 2.278317e-07 8.807664e-08 9.716474e-08 1.372644e-07 1.501705e-07 2.285512e-08 9.67386e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456028e-08 5.034151e-08 2.59798e-07 1.838301e-07 6.343761e-08 8.150528e-08 5.003339e-09 9.525829e-08 2.182819e-07 1.843608e-07 1.384995e-08 6.420205e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478402e-07 1.590096e-07 3.480242e-07 1.698739e-07 5.310051e-08 4.452434e-08 4.024406e-09 5.150024e-08 2.914618e-08 1.276424e-07 1.826463e-07 3.983407e-07 5.450437e-08 2.266521e-07 1.942693e-07 7.255422e-08 1.167992e-07 1.510635e-07 6.353524e-08 7.878904e-08 3.480648e-08 4.585462e-09
1.564492e-07 1.122885e-07 8.888715e-08 1.762501e-07 8.600158e-09 1.938033e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498502e-08 5.176819e-08 1.897708e-07 8.593463e-08 4.070766e-08 7.402083e-08 2.278317e-07 8.807678e-08 9.716461e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673845e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456013e-08 5.034177e-08 2.597981e-07 1.838302e-07 6.343747e-08 8.150528e-08 5.003199e-09 9.525842e-08 2.18282e-07 1.843611e-07 1.38501e-08 6.420349e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698737e-07 5.310065e-08 4.452434e-08 4.024266e-09 5.150011e-08 2.914633e-08 1.276427e-07 1.826462e-07 3.983409e-07 5.45045e-08 2.266521e-07 1.942694e-07 7.255437e-08 1.167993e-07 1.510638e-07 6.353538e-08 7.878917e-08 3.480662e-08 4.585462e-09
1.564492e-07 1.122885e-07 8.888713e-08 1.762501e-07 8.600153e-09 1.938034e-07 2.387623e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498504e-08 5.176827e-08 1.897708e-07 8.593455e-08 4.070764e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716463e-08 1.372642e-07 1.501702e-07 2.285517e-08 9.673848e-08 2.987679 3.6553 2.987679 1.266363e-07 1.456022e-08 5.034178e-08 2.597982e-07 1.838302e-07 6.343753e-08 8.150528e-08 5.003255e-09 9.52584e-08 2.182821e-07 1.843611e-07 1.385001e-08 6.420325e-09 0.3078006 0.542824 0.3078008 2.254643e-07 1.478402e-07 1.590094e-07 3.48024e-07 1.698737e-07 5.310059e-08 4.452434e-08 4.024322e-09 5.150013e-08 2.91464e-08 1.276427e-07 1.826463e-07 3.983408e-07 5.450445e-08 2.266521e-07 1.942693e-07 7.255434e-08 1.167992e-07 1.510638e-07 6.353546e-08 7.878916e-08 3.480656e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888702e-08 1.762501e-07 8.600201e-09 1.938033e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.49852e-08 5.176818e-08 1.897709e-07 8.59346e-08 4.070753e-08 7.402099e-08 2.278317e-07 8.807662e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.285526e-08 9.673864e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456013e-08 5.034173e-08 2.597982e-07 1.838301e-07 6.343763e-08 8.150528e-08 5.00336e-09 9.525829e-08 2.182821e-07 1.84361e-07 1.38501e-08 6.420163e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478403e-07 1.590094e-07 3.480241e-07 1.698739e-07 5.310049e-08 4.452434e-08 4.024427e-09 5.150024e-08 2.914636e-08 1.276426e-07 1.826462e-07 3.983407e-07 5.450442e-08 2.266521e-07 1.942693e-07 7.255419e-08 1.167993e-07 1.510638e-07 6.353542e-08 7.878904e-08 3.480646e-08 4.585462e-09
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
1.564492e-07 1.122885e-07 8.888702e-08 1.762501e-07 8.600116e-09 1.938031e-07 2.387619e-07 2.871269e-07 2.21893e-07 1.239548e-07 8.498547e-08 5.176805e-08 1.897708e-07 8.59346e-08 4.070753e-08 7.402088e-08 2.278317e-07 8.807672e-08 9.716474e-08 1.372642e-07 1.501702e-07 2.28554e-08 9.673891e-08 2.987679 3.6553 2.987679 1.266367e-07 1.456e-08 5.034181e-08 2.597982e-07 1.838301e-07 6.343753e-08 8.150528e-08 5.003254e-09 9.525829e-08 2.182821e-07 1.843611e-07 1.385024e-08 6.419893e-09 0.3078006 0.542824 0.3078008 2.254647e-07 1.478404e-07 1.590093e-07 3.480241e-07 1.698739e-07 5.31006e-08 4.452434e-08 4.024321e-09 5.150024e-08 2.914635e-08 1.276427e-07 1.82646e-07 3.983404e-07 5.450418e-08 2.266521e-07 1.942691e-07 7.255392e-08 1.167995e-07 1.510639e-07 6.353541e-08 7.878904e-08 3.480657e-08 4.585462e-09
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600304e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.49849e-08 5.176842e-08 1.89771e-07 8.593476e-08 4.070759e-08 7.402079e-08 2.278317e-07 8.807682e-08 9.716468e-08 1.372644e-07 1.501704e-07 2.285502e-08 9.673834e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456037e-08 5.034163e-08 2.59798e-07 1.838302e-07 6.343743e-08 8.150528e-08 5.003165e-09 9.525835e-08 2.182819e-07 1.843609e-07 1.384986e-08 6.420458e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024231e-09 5.150018e-08 2.914619e-08 1.276425e-07 1.826464e-07 3.98341e-07 5.45043e-08 2.266521e-07 1.942692e-07 7.255448e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.87891e-08 3.480666e-08 4.585462e-09
1.564492e-07 1.122883e-07 8.888687e-08 1.762498e-07 8.600378e-09 1.938033e-07 2.387621e-07 2.87127e-07 2.21893e-07 1.239547e-07 8.498525e-08 5.176826e-08 1.89771e-07 8.593487e-08 4.070738e-08 7.402101e-08 2.278317e-07 8.80766e-08 9.716489e-08 1.372645e-07 1.501704e-07 2.285518e-08 9.673868e-08 2.987679 3.6553 2.987679 1.266365e-07 1.456021e-08 5.034155e-08 2.597979e-07 1.8383e-07 6.343765e-08 8.150528e-08 5.003383e-09 9.525814e-08 2.182818e-07 1.843608e-07 1.385002e-08 6.420119e-09 0.3078006 0.542824 0.3078008 2.254645e-07 1.478402e-07 1.590096e-07 3.480243e-07 1.69874e-07 5.310046e-08 4.452434e-08 4.02445e-09 5.150039e-08 2.914608e-08 1.276425e-07 1.826463e-07 3.983406e-07 5.450425e-08 2.266521e-07 1.942691e-07 7.255414e-08 1.167992e-07 1.510636e-07 6.353514e-08 7.878889e-08 3.480644e-08 4.585462e-09
1.564492e-07 1.122883e-07 8.888674e-08 1.762497e-07 8.60019e-09 1.938031e-07 2.387623e-07 2.871273e-07 2.21893e-07 1.239544e-07 8.498511e-08 5.176805e-08 1.897708e-07 8.593494e-08 4.070725e-08 7.402105e-08 2.278317e-07 8.807655e-08 9.716502e-08 1.372645e-07 1.501702e-07 2.285539e-08 9.673855e-08 2.987679 3.6553 2.987679 1.266364e-07 1.456e-08 5.034174e-08 2.597978e-07 1.838298e-07 6.343769e-08 8.150528e-08 5.00342e-09 9.525801e-08 2.182817e-07 1.84361e-07 1.385023e-08 6.420252e-09 0.3078006 0.542824 0.3078008 2.254644e-07 1.478404e-07 1.590094e-07 3.480244e-07 1.698741e-07 5.310043e-08 4.452434e-08 4.024487e-09 5.150052e-08 2.914602e-08 1.276427e-07 1.82646e-07 3.983407e-07 5.450456e-08 2.266521e-07 1.942695e-07 7.255427e-08 1.167994e-07 1.510638e-07 6.353508e-08 7.878877e-08 3.48064e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888679e-08 1.762497e-07 8.600452e-09 1.938035e-07 2.387627e-07 2.871276e-07 2.21893e-07 1.239541e-07 8.49847e-08 5.176846e-08 1.897711e-07 8.593501e-08 4.07073e-08 7.402098e-08 2.278317e-07 8.807663e-08 9.716497e-08 1.372646e-07 1.501705e-07 2.285498e-08 9.673813e-08 2.987679 3.6553 2.987679 1.266359e-07 1.456041e-08 5.034148e-08 2.597978e-07 1.838299e-07 6.343762e-08 8.150528e-08 5.003356e-09 9.525806e-08 2.182816e-07 1.843608e-07 1.384982e-08 6.42067e-09 0.3078006 0.542824 0.3078008 2.254639e-07 1.4784e-07 1.590097e-07 3.480245e-07 1.698741e-07 5.310049e-08 4.452434e-08 4.024423e-09 5.150047e-08 2.914594e-08 1.276424e-07 1.826465e-07 3.983412e-07 5.450482e-08 2.266521e-07 1.942697e-07 7.255469e-08 1.16799e-07 1.510635e-07 6.3535e-08 7.878881e-08 3.480647e-08 4.585462e-09
1.564492e-07 1.122884e-07 8.888694e-08 1.762497e-07 8.600463e-09 1.938032e-07 2.387621e-07 2.871272e-07 2.21893e-07 1.239545e-07 8.498531e-08 5.17681e-08 1.897711e-07 8.593498e-08 4.070744e-08 7.402095e-08 2.278317e-07 8.807665e-08 9.716483e-08 1.372646e-07 1.501705e-07 2.285535e-08 9.673875e-08 2.987679 3.6553 2.987679 1.266366e-07 1.456004e-08 5.034147e-08 2.597978e-07 1.8383e-07 6.34376e-08 8.150528e-08 5.003324e-09 9.52582e-08 2.182817e-07 1.843607e-07 1.385019e-08 6.420059e-09 0.3078006 0.542824 0.3078008 2.254646e-07 1.478404e-07 1.590097e-07 3.480245e-07 1.698739e-07 5.310052e-08 4.452434e-08 4.024391e-09 5.150033e-08 2.914598e-08 1.276424e-07 1.826461e-07 3.983406e-07 5.450446e-08 2.266521e-07 1.942694e-07 7.255408e-08 1.167994e-07 1.510635e-07 6.353503e-08 7.878896e-08 3.48065e-08 4.585462e-09
1.564492e-07 1.122886e-07 8.888708e-08 1.762499e-07 8.600294e-09 1.938035e-07 2.387625e-07 2.871271e-07 2.21893e-07 1.239546e-07 8.498491e-08 5.176843e-08 1.89771e-07 8.593477e-08 4.070759e-08 7.40208e-08 2.278317e-07 8.807681e-08 9.716467e-08 1.372644e-07 1.501703e-07 2.285501e-08 9.673835e-08 2.987679 3.6553 2.987679 1.266362e-07 1.456038e-08 5.034164e-08 2.59798e-07 1.838302e-07 6.343744e-08 8.150528e-08 5.003168e-09 9.525836e-08 2.182819e-07 1.843609e-07 1.384985e-08 6.420451e-09 0.3078006 0.542824 0.3078008 2.254642e-07 1.478401e-07 1.590095e-07 3.480242e-07 1.698738e-07 5.310068e-08 4.452434e-08 4.024235e-09 5.150017e-08 2.914619e-08 1.276426e-07 1.826464e-07 3.983409e-07 5.450431e-08 2.266521e-07 1.942692e-07 7.255447e-08 1.167991e-07 1.510637e-07 6.353525e-08 7.878911e-08 3.480665e-08 4.585462e-09
0.07889044 0.08153418 0.08419043 0.08701882 0.09030894 0.09435995 0.09932118 0.1050815 0.1113084 0.1176306 0.1239187 0.1305645 0.1386389 0.1497532 0.1654913 0.1866337 0.2127252 0.2422527 0.2731474 0.3032434 0.3305583 0.3534314 0.3705729 3.007106 3.643108 3.00695 0.3692689 0.3515675 0.3282436 0.300618 0.2703725 0.239492 0.210109 0.1842086 0.1631676 0.1472882 0.1356852 0.1267589 0.1189501 0.3679682 0.5516769 0.2577798 0.08846281 0.08238595 0.07764257 0.07410715 0.07139895 0.06908786 0.06687377 0.06464756 0.06245382 0.06040037 0.05856909 0.05696621 0.05552716 0.0541646 0.05281365 0.05146411 0.05015175 0.04893123 0.04783951 0.04687591 0.0460047 0.04517756 0.04435825
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.0133671 0.103174 0.161102 0.1321454 0.01981753 0.1171069 0.1886141 0.1606799 0.03154613 0.1368369 0.230862 0.208004 0.05647711 0.1650847 0.3020293 0.298915 0.1252058 0.1979204 0.4401451 0.5311838 0.4472875 0.1536819 0.2990841 0.5738236 0.6613865 0.5702119 0.2925259 0.1586363 0.4468553 0.5274072 0.4351405 0.1946639 0.1232734 0.2943546 0.2986827 0.1671092 0.04655298 0.1966405 0.2236971 0.1398044 0.01435895 0.1384342 0.1696025 0.109466 0.009560954 0.1101514 0.1376043 0.08825757 0.01127213 0.09668601 0.1209162 0.07821064 0.008807627 0.0850097 0.1078746 0.07068323 0.006762736 0.07595089 0.09776526 0.06483996 0.005235632 0.0689388 0.08987227 0.0602516 0.004088645
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.01336695 0.1031739 0.161102 0.1321453 0.01981753 0.1171067 0.1886142 0.1606797 0.03154635 0.1368371 0.2308619 0.2080039 0.05647729 0.1650848 0.3020293 0.298915 0.1252055 0.1979204 0.440145 0.531184 0.4472874 0.1536818 0.299084 3.026 3.630765 3.02569 0.2925259 0.1586363 0.4468552 0.5274073 0.4351404 0.1946639 0.1232734 0.2943546 0.2986827 0.1671094 0.04655316 0.1966404 0.2236971 0.4124265 0.5511931 0.1922355 0.1696027 0.1094658 0.009561111 0.110151 0.1376044 0.08825763 0.01127217 0.09668601 0.1209163 0.07821067 0.008807753 0.08500987 0.1078743 0.07068328 0.00676251 0.07595107 0.0977652 0.06483985 0.005235482 0.06893886 0.0898722 0.06025163 0.004088641
0.3260067 0.3363645 0.3473228 0.3584008 0.3717065 0.3864512 0.401035 0.4184498 0.438931 0.4590358 0.4825051 0.5122178 0.5418503 0.5749596 0.620894 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 3.06281 3.479569 3.061841 1.720891 1.431183 1.139257 0.9739593 0.8857559 0.7909085 0.7123318 0.6630843 0.6163641 0.5723306 0.5442436 0.5254506 0.5046235 0.6600668 0.5916568 0.1053787 0.324299 0.3175806 0.3153523 0.3077486 0.2989039 0.2901882 0.2814592 0.2735063 0.2657846 0.2581551 0.2513817 0.2449855 0.2385924 0.2328572 0.2275815 0.2222788 0.2174162 0.213032 0.2086438 0.2045162 0.2008394 0.1972071 0.1937068
0.4438076 0.4559436 0.4704448 0.4847386 0.5020207 0.5190093 0.5398599 0.5602847 0.5858377 0.6107538 0.6427084 0.6736734 0.7147121 0.7541329 0.8087943 0.8606219 0.9373714 1.008648 1.125829 1.230708 1.440158 1.613244 2.198886 2.788961 2.987679 2.787503 2.195585 1.607394 1.433426 1.222683 1.117479 0.9998917 0.9291879 0.853102 0.8035754 0.7514675 0.7191642 0.6857824 0.6938878 0.7097453 0.6016893 0.4058476 0.4104768 0.4370055 0.4281905 0.4192973 0.4079929 0.3965591 0.3857173 0.3747567 0.3649065 0.3549583 0.346147 0.3372574 0.3294108 0.3215023 0.3145201 0.3074889 0.3012713 0.2950149 0.2894724 0.2838991 0.2789537 0.2739837 0.2695685
0.3260067 0.3363644 0.3473228 0.3584008 0.3717064 0.3864512 0.4010351 0.4184499 0.4389309 0.4590358 0.4825051 0.5122178 0.5418502 0.5749596 0.6208939 0.6692876 0.7196215 0.7983373 0.8930331 0.9810481 1.1455 1.435858 1.723972 1.914222 1.97871 1.912684 1.720891 1.431183 1.139257 0.9739592 0.885756 0.7909085 0.7123318 0.6630843 0.6163642 0.5723305 0.5442435 0.5254506 0.5046235 0.4722382 0.4203468 0.3605282 0.3242991 0.3175806 0.3153524 0.3077487 0.2989038 0.2901882 0.2814592 0.2735062 0.2657845 0.2581551 0.2513816 0.2449855 0.2385921 0.2328572 0.2275815 0.2222787 0.2174161 0.213032 0.2086437 0.2045162 0.2008394 0.1972071 0.1937068
0.07889045 0.08153424 0.08419035 0.08701893 0.09030894 0.09436007 0.09932096 0.1050817 0.1113084 0.1176305 0.1239188 0.1305645 0.1386389 0.1497532 0.1654913 0.1866336 0.2127252 0.2422527 0.2731474 0.3032433 0.3305584 0.3534314 0.3705729 0.3810754 0.3844073 0.3804049 0.3692688 0.3515675 0.3282437 0.3006178 0.2703726 0.239492 0.2101091 0.1842086 0.1631677 0.1472881 0.1356851 0.126759 0.1189501 0.1112676 0.1034146 0.09563871 0.0884626 0.08238604 0.07764256 0.07410739 0.07139879 0.06908784 0.06687377 0.06464756 0.06245377 0.06040035 0.05856907 0.05696608 0.05552753 0.05416456 0.05281367 0.05146397 0.05015182 0.0489313 0.04783952 0.04687586 0.04600478 0.04517753 0.04435825
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -46,6 +47,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
@@ -143,7 +145,7 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
}
val sdfTime = remember(isUtc) {
SimpleDateFormat("HH:mm:ss", Locale.ENGLISH).also { it.timeZone = timeZone }
SimpleDateFormat("HH:mm:ss", displayLocale()).also { it.timeZone = timeZone }
}
ElevatedCard(
modifier = modifier
@@ -157,13 +159,16 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${stringResource(R.string.pass_satId, pass.catNum)} - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -209,6 +214,11 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
}
}
private fun displayLocale(): Locale {
val locale = Locale.getDefault()
return if (locale.language == Locale.CHINESE.language) locale else Locale.ENGLISH
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
@@ -299,7 +309,8 @@ fun SharedDialog(
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
content: @Composable (padding: Dp) -> Unit
) {
DialogShell(onDismissRequest = onDismissRequest) { padding ->
val dismissible = onCancel != null
DialogShell(onDismissRequest = onDismissRequest, dismissible = dismissible) { padding ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -333,10 +344,14 @@ fun SharedDialog(
@Composable
private fun DialogShell(
onDismissRequest: () -> Unit,
dismissible: Boolean = true,
content: @Composable (padding: Dp) -> Unit
) {
val padding = LocalSpacing.current.large
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true,
confirmValueChange = { if (dismissible) true else it != SheetValue.Hidden }
)
val stopSheetFling = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
@@ -351,10 +366,10 @@ private fun DialogShell(
}
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
onDismissRequest = if (dismissible) onDismissRequest else { {} },
sheetState = sheetState,
dragHandle = null,
shape = MaterialTheme.shapes.medium,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
scrimColor = Color.Black.copy(alpha = 0.64f)
) {
Column(
@@ -518,5 +533,6 @@ fun formatFrequency(frequencyHz: Long): String {
if (frequencyHz <= 0) return "---"
val mhz = frequencyHz / 1_000_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)
}
@@ -21,45 +21,24 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
@Serializable
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
@Serializable
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
@Serializable
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
@Serializable
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
@Serializable
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
@Serializable
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
@Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
}
// UI settings: default main-menu page order (5 bottom-bar slots)
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
// UI settings: default More-menu order (pages behind "More")
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
@Serializable
data object RadarDestination : NavKey
@@ -88,4 +67,4 @@ class DeeplinkResolver(private val fallbackDestination: NavKey = Screen.Passes)
matchers.forEach { it.match(deeplink)?.let { match -> return match } }
return fallbackDestination
}
}
}
@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Morse style icon: a dot above a dash -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:fillColor="@android:color/white"
android:pathData="M13.91,6.75c-1.17,2.25 -4.3,5.31 -6.07,6.94c-0.19,0.172 -0.48,0.172 -0.67,0C5.39,12.06 2.26,9 1.09,6.75C-1.48,1.8 5,-1.5 7.5,3.45C10,-1.5 16.48,1.8 13.91,6.75z" />
</vector>
@@ -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="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
</vector>
@@ -1,22 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
</vector>
@@ -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="@android:color/white"
android:pathData="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
</vector>
@@ -1,25 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<!-- Crosshair / grid-locator style icon: concentric rings + center dot -->
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 48 12 L 48 84 M 12 48 L 84 48" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:pathData="M 48 26 A 22 22 0 1 0 48.1 26" />
<path
android:fillColor="@android:color/white"
android:pathData="M 44 44 L 52 44 L 52 52 L 44 52 Z" />
</vector>
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Ajustes</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_type_hint">Modos: %s</string>
<string name="sat_type_title">Seleccionar modos</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_group_selected">Seleccionados</string>
<string name="sat_group_available">Disponibles</string>
<string name="sat_group_selected_count">Seleccionados (%1$d)</string>
<string name="sat_group_available_count">Disponibles (%1$d)</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
@@ -26,7 +30,7 @@
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\n\nIntenta siempre limitar la lista a los que te
interesen mediante la búsqueda y el selector de tipos.</string>
interesen mediante la búsqueda y el selector de modos.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string>
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">Ventana AOS</string>
<string name="pass_filter_invert_time">Invertir ventana AOS</string>
<string name="pass_filter_deep_space">DeepSpace (período &gt;225min)</string>
<string name="pass_modes_title">Tipo de modulación</string>
<string name="pass_modes_title">Seleccionar modos</string>
<string name="pass_elevation">Elevación: %.1f°</string>
<string name="pass_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_station_title">Ubicación de la estación</string>
<string name="prefs_station_lat_text">Latitud de estación</string>
<string name="prefs_station_lon_text">Longitud de estación</string>
<string name="prefs_locator_title">Localizador QTH</string>
<string name="prefs_locator_text">Posición por localizador</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_import_satellites_error">No se importaron satélites. Usa un TLE/3LE (.txt) u OMM (.csv) válido.</string>
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Salida de datos</string>
<string name="prefs_net_output">Red</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Control de radio</string>
<string name="rc_settings_title">Control CAT</string>
<string name="rc_radio_model">Modelo de radio</string>
<string name="rc_tx_device_hint">Dirección BT TX</string>
<string name="rc_rx_device_hint">Dirección BT RX</string>
<string name="rc_tx_name_hint">Nombre radio TX</string>
<string name="rc_rx_name_hint">Nombre radio RX</string>
<string name="rc_enable_switch">Activar CAT</string>
<string name="prefs_net_title">Salida de red</string>
<string name="prefs_net_rotator_switch">Activar salida de rotación</string>
<string name="prefs_net_rotator_address_hint">IP:Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_net_frequency_address_hint">IP:Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida Bluetooth</string>
<string name="prefs_bt_rotator_switch">Activar salida de rotación</string>
<string name="prefs_bt_rotator_device_hint">ID dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato</string>
<string name="prefs_bt_frequency_switch">Activar salida de frecuencia</string>
<string name="prefs_bt_frequency_device_hint">ID dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_net_perm_error">Revisa los permisos de red</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_other_switch_night_mode">Activar filtro nocturno rojo</string>
<string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string>
<string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -1,290 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -1,290 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Terima</string>
<string name="btn_cancel">Batal</string>
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
<string name="nav_sat">Satelit</string>
<string name="nav_pass">Lintasan</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Pasangan</string>
<string name="nav_roaming">Jelajah</string>
<string name="nav_cw">Dekoder CW</string>
<string name="nav_map">Peta</string>
<string name="nav_prefs">Pengaturan</string>
<string name="prefs_aprs_title">Pelaporan APRS</string>
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Tanda panggilan</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Kode sandi</string>
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
<string name="prefs_aprs_interval">Interval (menit)</string>
<string name="prefs_aprs_status">Teks status</string>
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
<string name="prefs_aprs_symbol_code">Kode simbol</string>
<string name="prefs_aprs_save">Simpan</string>
<string name="prefs_aprs_cancel">Batal</string>
<string name="aprs_notif_channel">Layanan APRS</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Pelaporan berjalan</string>
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
<string name="aprs_notif_error">Kesalahan koneksi</string>
<string name="aprs_notif_stop">Berhenti</string>
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
<string name="nav_more">Lainnya</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">Status Satelit AMSAT</string>
<string name="amsat_refresh">Segarkan</string>
<string name="amsat_retry">Coba lagi</string>
<string name="amsat_updated">Diperbarui:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/beacon</string>
<string name="amsat_not_heard">Tidak terdengar</string>
<string name="amsat_conflict">Konflik</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
<string name="amsat_close">Tutup</string>
<string name="sat_type_hint">Jenis: %s</string>
<string name="sat_type_title">Pilih jenis satelit</string>
<string name="sat_search_hint">Id - Nama</string>
<string name="sat_search_clear">Bersihkan</string>
<string name="sat_clear_all">Bersihkan semua</string>
<string name="sat_select_all">Pilih semua</string>
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
<string name="sat_warning_title">Peringatan\!</string>
<string name="sat_warning_message">
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
Tidak masuk akal untuk melacak semuanya sekaligus.
\n\nSelalu coba persempit daftar hanya ke satelit yang
Anda minati melalui pencarian dan pemilih jenis.</string>
<string name="pass_filter_title">Filter lintasan</string>
<string name="pass_filter_elev">Elevasi minimal</string>
<string name="pass_filter_hours">Waktu ke depan</string>
<string name="pass_filter_aos_time">Jendela AOS</string>
<string name="pass_filter_invert_time">Balik jendela AOS</string>
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Pilih jenis modulasi</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevasi: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Ketinggian: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
<string name="pass_welcome_message">
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* BARU: Decoder CW kini memakai jaringan saraf DeepCW — jauh lebih baik menafsir Morse sinyal lemah
* BARU: Riwayat CW tersimpan permanen (teks tidak lagi hilang)
* Waterfall CW: skema warna inferno baru dengan gradasi halus
* Dukungan 64-bit (arm64) dipulihkan
* Perbaikan: jeda/lanjut tidak lagi menampilkan galat "gagal menafsir" yang keliru
</string>
<string name="radar_back">Kembali</string>
<string name="radar_notify">Beri tahu</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevasi</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Ketinggian</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Jarak</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Gerhana</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
<string name="radar_add">Tambah</string>
<string name="radar_data">Transceiver satelit:</string>
<string name="radar_inverted">Terbalik: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">Tidak</string>
<string name="radar_string_yes">Ya</string>
<string name="radar_visible">Terlihat</string>
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
<string name="radar_cw_decoder">Dekoder CW</string>
<string name="radar_cw_start">Mulai</string>
<string name="radar_cw_stop">Berhenti</string>
<string name="radar_cw_reset">Bersihkan</string>
<string name="radar_cw_tone">Nada %1$d Hz</string>
<string name="radar_cw_waiting">Menunggu sinyal…</string>
<string name="map_prev">Sebelumnya</string>
<string name="map_next">Berikutnya</string>
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevasi: %.1f°</string>
<string name="map_altitude">Ketinggian: %.0f km</string>
<string name="map_distance">Jarak: %.0f km</string>
<string name="map_latitude">Lintang: %.1f°</string>
<string name="map_longitude">Bujur: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Gerhana</string>
<string name="map_period">Periode: %.0f mnt</string>
<string name="map_velocity">Kecepatan: %.2f km/d</string>
<string name="map_visibility">Visibilitas: %s</string>
<string name="map_visible">Terlihat</string>
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donasi</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Kebijakan Privasi</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Diperbarui: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Posisi stasiun</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
<string name="prefs_data_title">Data satelit</string>
<string name="prefs_data_entries">Satelit: %s</string>
<string name="prefs_data_radios">Transceiver: %s</string>
<string name="prefs_data_update">Perbarui</string>
<string name="prefs_data_import">URL Kustom</string>
<string name="prefs_data_clear">Bersihkan</string>
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
<string name="prefs_data_sources_title">Sumber data kustom</string>
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
<string name="prefs_data_output_title">Keluaran data</string>
<string name="prefs_net_output">Jaringan</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">Kontrol Radio</string>
<string name="rc_settings_title">Kontrol Radio CAT</string>
<string name="rc_radio_model">Model Radio</string>
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
<string name="rc_tx_name_hint">Nama Radio TX</string>
<string name="rc_rx_name_hint">Nama Radio RX</string>
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
<string name="prefs_net_title">Keluaran data jaringan</string>
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
<string name="prefs_bt_rotator_output_hint">Format data</string>
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
<string name="prefs_bt_frequency_output_hint">Format data</string>
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
<string name="tracking_status">Melacak: %s</string>
<string name="loc_lat_text">Lintang: %s°</string>
<string name="loc_lon_text">Bujur: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Hubungkan</string>
<string name="disconnect_radio">Putuskan</string>
<string name="track_radio">Lacak</string>
<string name="stop_radio">Berhenti</string>
<string name="prefs_wavelog_title">Server log WaveLog</string>
<string name="prefs_wavelog_url_hint">Alamat server</string>
<string name="prefs_wavelog_key_hint">Kunci API</string>
<string name="prefs_wavelog_station_hint">ID stasiun</string>
<string name="prefs_wavelog_auto">Unggah otomatis</string>
<string name="prefs_wavelog_test">Uji koneksi</string>
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
<string name="prefs_wavelog_upload">Unggah sekarang</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Pilih transponder</string>
<string name="wavelog_call_hint">Tanda panggil</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Tersimpan ke log lokal</string>
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
<string name="wavelog_empty">Belum ada entri log</string>
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
<string name="wavelog_ignore">Abaikan dan unggah</string>
<string name="wavelog_cancel">Batal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Salin</string>
<string name="wavelog_delete_undo">Urungkan</string>
<string name="wavelog_col_time">Waktu</string>
<string name="wavelog_col_freq">Frek.</string>
<string name="wavelog_col_sat">Satelit</string>
<string name="wavelog_col_call">Panggilan</string>
<string name="wavelog_col_uploaded">Terkirim</string>
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
<string name="prefs_other_title">Pengaturan lainnya</string>
<string name="prefs_ui_title">Pengaturan UI</string>
<string name="prefs_ui_order_title">Urutan halaman</string>
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
<string name="prefs_ui_sub_title">Menu lainnya</string>
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
<string name="prefs_highlight_title">Sorotan elevasi</string>
<string name="prefs_highlight_low">Rendah &lt; %1$d°</string>
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
<string name="prefs_highlight_high">Tinggi &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; penulis asli mengucapkan terima kasih</string>
<string name="prefs_outro_thanks" translatable="false">
* Pengguna dan kontributor Look4Sat!
\n* David A. B. Johnson (predict4java)
\n* Dave Moten (predict4java)
\n* Alexandru Csete (Gpredict)
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl dan kontributor Robot36!
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
</resources>
@@ -13,19 +13,23 @@
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<string name="sat_type_hint">Режимы: %s</string>
<string name="sat_type_title">Выберите режимы</string>
<string name="sat_search_hint">Id - Название</string>
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_group_selected">Выбранные</string>
<string name="sat_group_available">Доступные</string>
<string name="sat_group_selected_count">Выбранные (%1$d)</string>
<string name="sat_group_available_count">Доступные (%1$d)</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
В этом приложении перечислено более 9000 спутников.
Отслеживать их все одновременно бессмысленно.
\n\nВсегда старайтесь сузить список до тех,
которые вас интересуют, используя поиск и селектор типов.</string>
которые вас интересуют, используя поиск и выбор режимов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
@@ -34,7 +38,7 @@
<string name="pass_filter_aos_time">Окно AOS</string>
<string name="pass_filter_invert_time">Инвертировать окно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (период &gt;225мин)</string>
<string name="pass_modes_title">Выберите тип модуляции</string>
<string name="pass_modes_title">Выберите режимы</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
@@ -95,11 +99,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_station_title">Положение станции</string>
<string name="prefs_station_lat_text">Широта станции</string>
<string name="prefs_station_lon_text">Долгота станции</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиция по локатору</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
@@ -109,16 +113,30 @@
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
<string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_import_satellites_error">Спутники не импортированы. Нужен TLE/3LE (.txt) или OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_data_output_title">Вывод</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="nav_radiocontrol">Управление радио</string>
<string name="rc_settings_title">CAT управление</string>
<string name="rc_radio_model">Модель радио</string>
<string name="rc_tx_device_hint">BT адрес TX</string>
<string name="rc_rx_device_hint">BT адрес RX</string>
<string name="rc_tx_name_hint">Имя радио TX</string>
<string name="rc_rx_name_hint">Имя радио RX</string>
<string name="rc_enable_switch">Включить CAT</string>
<string name="prefs_net_title">Сетевой вывод</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
@@ -126,22 +144,22 @@
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
<string name="prefs_bt_title">Вывод Bluetooth</string>
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
<string name="prefs_bt_rotator_device_hint">ID устройства</string>
<string name="prefs_bt_rotator_output_hint">Формат</string>
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
<string name="prefs_bt_frequency_device_hint">ID устройства</string>
<string name="prefs_bt_frequency_output_hint">Формат</string>
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_net_perm_error">Нет разрешения использовать сеть</string>
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_other_switch_night_mode">Включить красный ночной фильтр</string>
<string name="prefs_other_title">Прочее</string>
<string name="prefs_other_switch_utc">Время в UTC</string>
<string name="prefs_other_switch_update">Автообновление данных</string>
<string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -13,19 +13,23 @@
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<string name="sat_type_hint">මාදිලි: %s</string>
<string name="sat_type_title">මාදිලි තෝරන්න</string>
<string name="sat_search_hint">Id - නම</string>
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_group_selected">තෝරාගත්</string>
<string name="sat_group_available">තිබෙන</string>
<string name="sat_group_selected_count">තෝරාගත් (%1$d)</string>
<string name="sat_group_available_count">තිබෙන (%1$d)</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
<string name="sat_warning_message">
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ.
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
\n\nසෙවීම සහ මාදිලි තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">AOS කවුළුව</string>
<string name="pass_filter_invert_time">AOS කවුළුව ප්‍රතිවිරුද්ධ කරන්න</string>
<string name="pass_filter_deep_space">DeepSpace (කාලය &gt;225min)</string>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
<string name="pass_modes_title">මූර්ජන තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
<string name="pass_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_station_title">පොළ පිහිටීම</string>
<string name="prefs_station_lat_text">පොළ අක්‍ෂාංශය</string>
<string name="prefs_station_lon_text">පොළ දේශාංශය</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">locator මඟින් පොළ පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_import_satellites_error">චන්ද්‍රිකා ආයාත නොවීය. වලංගු TLE/3LE (.txt) හෝ OMM (.csv) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">දත්ත ප්‍රතිදානය</string>
<string name="prefs_net_output">ජාලය</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="nav_radiocontrol">රේඩියෝ පාලනය</string>
<string name="rc_settings_title">CAT පාලනය</string>
<string name="rc_radio_model">රේඩියෝ මාදිලිය</string>
<string name="rc_tx_device_hint">TX BT ලිපිනය</string>
<string name="rc_rx_device_hint">RX BT ලිපිනය</string>
<string name="rc_tx_name_hint">TX රේඩියෝ නම</string>
<string name="rc_rx_name_hint">RX රේඩියෝ නම</string>
<string name="rc_enable_switch">CAT සබල කරන්න</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_rotator_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_net_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_format_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Rotation ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_rotator_device_hint">උපාංග ID</string>
<string name="prefs_bt_rotator_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_frequency_switch">Frequency ප්‍රතිදානය සබල කරන්න</string>
<string name="prefs_bt_frequency_device_hint">උපාංග ID</string>
<string name="prefs_bt_frequency_output_hint">දත්ත ආකෘතිය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_net_perm_error">Network අවසර පරික්‍ෂා කරන්න</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_other_switch_night_mode">රතු රාත්‍රී පෙරහන සබල කරන්න</string>
<string name="prefs_other_title">වෙනත්</string>
<string name="prefs_other_switch_utc">UTC වේලාව</string>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -10,69 +10,27 @@
<string name="nav_sat">Uydular</string>
<string name="nav_pass">Geçişler</string>
<string name="nav_radar">Radar</string>
<string name="nav_cw">CW Dekoder</string>
<string name="nav_map">Harita</string>
<string name="nav_prefs">Ayarlar</string>
<string name="prefs_aprs_title">APRS Raporlama</string>
<string name="prefs_aprs_enable">APRS raporlamayı etkinleştir</string>
<string name="prefs_aprs_summary">Sunucu: %1$s:%2$d Çağrı: %3$s</string>
<string name="prefs_aprs_report_now">Şimdi Raporla</string>
<string name="prefs_aprs_settings_title">APRS Ayarları</string>
<string name="prefs_aprs_server">Sunucu</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Çağrı işareti</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Parola</string>
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
<string name="prefs_aprs_interval">Aralık (dakika)</string>
<string name="prefs_aprs_status">Durum metni</string>
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
<string name="prefs_aprs_symbol_code">Sembol kodu</string>
<string name="prefs_aprs_save">Kaydet</string>
<string name="prefs_aprs_cancel">İptal</string>
<string name="aprs_notif_channel">APRS hizmeti</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Raporlama çalışıyor</string>
<string name="aprs_notif_connected">APRS-IS bağlı</string>
<string name="aprs_notif_error">Bağlantı hatası</string>
<string name="aprs_notif_stop">Durdur</string>
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
<string name="nav_more">Daha Fazla</string>
<string name="nav_log">Günlük</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Uydu Durumu</string>
<string name="amsat_refresh">Yenile</string>
<string name="amsat_retry">Tekrar dene</string>
<string name="amsat_updated">Güncellendi:</string>
<string name="amsat_active">Aktif</string>
<string name="amsat_tlm">TLM/işaret</string>
<string name="amsat_not_heard">Duyulmadı</string>
<string name="amsat_conflict">Çelişkili</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
<string name="amsat_close">Kapat</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Uydu türü: %s</string>
<string name="sat_type_title">Uydu türü seçin</string>
<string name="sat_type_hint">Modlar: %s</string>
<string name="sat_type_title">Modları seçin</string>
<string name="sat_search_hint">Id - Ad</string>
<string name="sat_search_clear">Temizle</string>
<string name="sat_clear_all">Tümünü temizle</string>
<string name="sat_select_all">Tümünü seç</string>
<string name="sat_group_selected">Seçilenler</string>
<string name="sat_group_available">Kullanılabilir</string>
<string name="sat_group_selected_count">Seçilenler (%1$d)</string>
<string name="sat_group_available_count">Kullanılabilir (%1$d)</string>
<string name="sat_empty_list_message">"Arama sorgunuzun doğru olduğundan ve veritabanının güncellendiğinden emin olun"</string>
<string name="sat_warning_title">Uyarı\!</string>
<string name="sat_warning_message">
Bu uygulamada 9000\'den fazla uydu listelenmiştir.
Hepsini aynı anda takip etmek anlamsızdır.
\n\nListeyi yalnızca ilgilendiğiniz uydularla
daraltmak için arama ve tür seçiciyi kullanın.</string>
daraltmak için arama ve mod seçiciyi kullanın.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Geçişleri filtrele</string>
@@ -82,7 +40,7 @@
<string name="pass_filter_invert_time">AOS aralığını tersine çevir</string>
<string name="pass_filter_deep_space">DeepSpace (periyot &gt;225dk)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Modulasyon türü seçin</string>
<string name="pass_modes_title">Modları seçin</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Yükseklik açısı: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -96,12 +54,12 @@
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* YENİ: CW çözücü artık DeepCW sinir ağını kullanıyor — zayıf sinyal Morse çözümü çok daha iyi
* YENİ: CW çözüm geçmişi kalıcı olarak saklanır (metin artık kaybolmaz)
* CW şelale: yeni inferno renk şeması ve yumuşak geçişler
* 64-bit (arm64) desteği geri geldi
* Düzeltme: duraklat/devam artık hatalı "çözümleme başarısız" mesajı göstermiyor
<string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
\n* Added multiple performance tweaks, improved passes calculation
\n* Fixed UTC pass time problem mentioned in issue #201
</string>
<!-- Radar screen -->
@@ -149,7 +107,7 @@
<string name="map_visible">Görünür</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -167,50 +125,50 @@
<string name="prefs_loc_title">İstasyon konumu</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
<string name="prefs_loc_input_title">Manuel giriş</string>
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Geçersiz QTH konumlandırıcısı</string>
<string name="prefs_loc_success">Konum başarıyla güncellendi</string>
<string name="prefs_station_title">İstasyon konumu ayarları</string>
<string name="prefs_station_lat_text">Yer istasyonunuzun enlemini girin</string>
<string name="prefs_station_lon_text">Yer istasyonunuzun boylamını girin</string>
<string name="prefs_locator_title">QTH konumlandırıcı ayarları</string>
<string name="prefs_locator_text">İstasyon konumunu konumlandırıcı ile ayarlayın</string>
<string name="prefs_station_title">İstasyon konumu</string>
<string name="prefs_station_lat_text">İstasyon enlemi</string>
<string name="prefs_station_lon_text">İstasyon boylamı</string>
<string name="prefs_locator_title">QTH konumlandırıcı</string>
<string name="prefs_locator_text">Konumu lokatörle ayarla</string>
<string name="prefs_data_title">Uydu verisi</string>
<string name="prefs_data_entries">Uydular: %s</string>
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
<string name="prefs_data_update">Güncelle</string>
<string name="prefs_data_import">Özel URL</string>
<string name="prefs_data_import">İçe aktar</string>
<string name="prefs_data_clear">Temizle</string>
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
<string name="prefs_data_import_satellites_error">Uydu içe aktarılmadı. Geçerli bir TLE/3LE (.txt) veya OMM (.csv) dosyası seçin.</string>
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
<string name="prefs_data_output_title">Veri aktarımı</string>
<string name="prefs_data_output_title">Veri çıkışı</string>
<string name="prefs_net_output">Ağ</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">Radyo Kontrolü</string>
<string name="rc_settings_title">CAT Radyo Kontrolü</string>
<string name="rc_settings_title">CAT kontrolü</string>
<string name="rc_radio_model">Radyo Modeli</string>
<string name="rc_tx_device_hint">TX Radyo BT Adresi</string>
<string name="rc_rx_device_hint">RX Radyo BT Adresi</string>
<string name="rc_tx_device_hint">TX BT adresi</string>
<string name="rc_rx_device_hint">RX BT adresi</string>
<string name="rc_tx_name_hint">TX Radyo Adı</string>
<string name="rc_rx_name_hint">RX Radyo Adı</string>
<string name="rc_enable_switch">CAT Kontrolünü Etkinleştir</string>
<string name="rc_enable_switch">CAT\'i etkinleştir</string>
<string name="prefs_net_title">Ağ veri çıkışı</string>
<string name="prefs_net_title">Ağ çıkışı</string>
<string name="prefs_net_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Biçim</string>
@@ -218,85 +176,36 @@
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Biçim</string>
<string name="prefs_bt_title">Bluetooth veri çıkışı</string>
<string name="prefs_bt_title">Bluetooth çıkışı</string>
<string name="prefs_bt_rotator_switch">Döndürme çıkışını etkinleştir</string>
<string name="prefs_bt_rotator_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_rotator_output_hint">Veri biçimi</string>
<string name="prefs_bt_rotator_output_hint">Biçim</string>
<string name="prefs_bt_frequency_switch">Frekans çıkışını etkinleştir</string>
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</string>
<string name="prefs_bt_frequency_output_hint">Veri biçimi</string>
<string name="prefs_bt_frequency_output_hint">Biçim</string>
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
<string name="tracking_status">İzleniyor: %s</string>
<string name="loc_lat_text">Enlem: %s°</string>
<string name="loc_lon_text">Boylam: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">Bağlan</string>
<string name="disconnect_radio">Bağlantıyı Kes</string>
<string name="track_radio">Takip</string>
<string name="stop_radio">Dur</string>
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
<string name="prefs_wavelog_key_hint">API anahtarı</string>
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
<string name="prefs_wavelog_update_sats">Uydu güncelle</string>
<string name="prefs_wavelog_upload">Şimdi yükle</string>
<string name="wavelog_title">Günlük</string>
<string name="wavelog_select_transponder">Transponder seç</string>
<string name="wavelog_call_hint">Çağrı işareti</string>
<string name="wavelog_mode_hint">Mod</string>
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
<string name="wavelog_ignore">Yoksay ve yükle</string>
<string name="wavelog_cancel">İptal</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">QRZ giriş çerezlerinizi buraya yapıştırın (EditThisCookie dışa aktarımı veya "k=v; k=v" biçimi). Yükleme için karşı istasyonun grid karesini getirmekte kullanılır.</string>
<string name="prefs_wavelog_qrz_label">Çerez</string>
<string name="wavelog_copy">Kopyala</string>
<string name="wavelog_delete_undo">Geri al</string>
<string name="wavelog_col_time">Zaman</string>
<string name="wavelog_col_freq">Frekans</string>
<string name="wavelog_col_sat">Uydu</string>
<string name="wavelog_col_call">Çağrı</string>
<string name="wavelog_col_uploaded">Gönderildi</string>
<string name="wavelog_ungrouped">Gruplanmamış</string>
<string name="prefs_other_title">Diğer ayarlar</string>
<string name="prefs_ui_title">UI Ayarları</string>
<string name="prefs_ui_order_title">Sayfa sırası</string>
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
<string name="prefs_ui_sub_title">Daha fazla menü</string>
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
<string name="prefs_other_switch_sensors">Radar görünümünü döndürmek için sensörleri kullan</string>
<string name="prefs_other_switch_night_mode">Kırmızı gece filtresini etkinleştir</string>
<string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
<string name="prefs_highlight_mid">Orta %1$d-%2$d°</string>
<string name="prefs_highlight_high">Yüksek &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA ve orijinal yazar teşekkür eder</string>
<string name="prefs_outro_title">Teşekkürler</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
<string name="radar_cw_tone">Ton %1$d Hz</string>
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
</resources>
@@ -13,12 +13,16 @@
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<string name="sat_type_hint">Режими: %s</string>
<string name="sat_type_title">Виберіть режими</string>
<string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_group_selected">Обрані</string>
<string name="sat_group_available">Доступні</string>
<string name="sat_group_selected_count">Обрані (%1$d)</string>
<string name="sat_group_available_count">Доступні (%1$d)</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
@@ -26,7 +30,7 @@
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
які вас цікавлять, за допомогою пошуку та вибору режимів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
@@ -35,7 +39,7 @@
<string name="pass_filter_aos_time">Вікно AOS</string>
<string name="pass_filter_invert_time">Інвертувати вікно AOS</string>
<string name="pass_filter_deep_space">DeepSpace (період &gt;225хв)</string>
<string name="pass_modes_title">Виберіть тип модуляції</string>
<string name="pass_modes_title">Виберіть режими</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
@@ -96,11 +100,11 @@
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_station_title">Позиція станції</string>
<string name="prefs_station_lat_text">Широта станції</string>
<string name="prefs_station_lon_text">Довгота станції</string>
<string name="prefs_locator_title">Локатор QTH</string>
<string name="prefs_locator_text">Позиція за локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
@@ -110,39 +114,52 @@
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_import_satellites_error">Супутники не імпортовано. Потрібен TLE/3LE (.txt) або OMM (.csv).</string>
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_data_output_title">Вивід даних</string>
<string name="prefs_net_output">Мережа</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="nav_radiocontrol">Керування радіо</string>
<string name="rc_settings_title">CAT керування</string>
<string name="rc_radio_model">Модель радіо</string>
<string name="rc_tx_device_hint">BT адреса TX</string>
<string name="rc_rx_device_hint">BT адреса RX</string>
<string name="rc_tx_name_hint">Назва радіо TX</string>
<string name="rc_rx_name_hint">Назва радіо RX</string>
<string name="rc_enable_switch">Увімкнути CAT</string>
<string name="prefs_net_title">Мережевий вивід</string>
<string name="prefs_net_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_net_rotator_address_hint">IP:Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_net_frequency_address_hint">IP:Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_title">Вивід Bluetooth</string>
<string name="prefs_bt_rotator_switch">Увімкнути вивід повороту</string>
<string name="prefs_bt_rotator_device_hint">ID пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_switch">Увімкнути вивід частоти</string>
<string name="prefs_bt_frequency_device_hint">ID пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_net_perm_error">Перевірте дозвіл мережі</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_other_switch_night_mode">Увімкнути червоний нічний фільтр</string>
<string name="prefs_other_title">Інше</string>
<string name="prefs_other_switch_utc">Час в UTC</string>
<string name="prefs_other_switch_update">Автооновлення даних</string>
<string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -9,67 +9,23 @@
<string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_mutual">匹配</string>
<string name="nav_roaming">漫游</string>
<string name="nav_cw">CW解码</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<string name="prefs_aprs_title">APRS 上报</string>
<string name="prefs_aprs_enable">启用 APRS 上报</string>
<string name="prefs_aprs_summary">服务器: %1$s:%2$d 呼号: %3$s</string>
<string name="prefs_aprs_report_now">立即上报</string>
<string name="prefs_aprs_settings_title">APRS 设置</string>
<string name="prefs_aprs_server">服务器</string>
<string name="prefs_aprs_port">端口</string>
<string name="prefs_aprs_callsign">呼号</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">验证码</string>
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
<string name="prefs_aprs_interval">周期(分钟)</string>
<string name="prefs_aprs_status">状态文本</string>
<string name="prefs_aprs_symbol_table">符号表</string>
<string name="prefs_aprs_symbol_code">符号码</string>
<string name="prefs_aprs_save">保存</string>
<string name="prefs_aprs_cancel">取消</string>
<string name="aprs_notif_channel">APRS 服务</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">上报运行中</string>
<string name="aprs_notif_connected">已连接 APRS-IS</string>
<string name="aprs_notif_error">连接错误</string>
<string name="aprs_notif_stop">停止</string>
<string name="prefs_aprs_calc_passcode">计算验证码</string>
<string name="aprs_toast_ok">APRS: 上报成功</string>
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">成功</string>
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
<string name="nav_more">更多</string>
<string name="nav_log">日志</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">仅遥测</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">矛盾</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
<string name="amsat_no_reports">该时段无报告</string>
<string name="amsat_close">关闭</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_group_selected">已选择</string>
<string name="sat_group_available">可用</string>
<string name="sat_group_selected_count">已选择(%1$d)</string>
<string name="sat_group_available_count">可用(%1$d)</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和模式筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
@@ -78,21 +34,13 @@
<string name="pass_filter_aos_time">AOS 时间段</string>
<string name="pass_filter_invert_time">反选 AOS 时间段</string>
<string name="pass_filter_deep_space">DeepSpace(周期 &gt;225分钟)</string>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_modes_title">选择模式</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
<string name="pass_whatsnew_message" translatable="false">
* 新: CW 解码改用 DeepCW 神经网络,弱信号摩尔斯码解码率大幅提升
* 新: CW 解码历史永久保留(文字不再消失)
* CW 瀑布图: 全新 inferno 配色与平滑渐变
* 恢复 64 位(arm64)支持
* 修复: 暂停/恢复不再误报"解码失败"
</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
@@ -112,19 +60,9 @@
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<string name="radar_doppler_calc">多普勒频率计算器</string>
<string name="radar_doppler_tx_hint">输入上行频率 (MHz)</string>
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<string name="radar_doppler_info">线性转发器:输入一个频率即可算出另一个</string>
<string name="radar_cw_decoder">CW 解码器</string>
<string name="radar_cw_start">开始</string>
<string name="radar_cw_stop">停止</string>
<string name="radar_cw_reset">清空</string>
<string name="radar_cw_tone">音调 %1$d Hz</string>
<string name="radar_cw_waiting">等待信号…</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
@@ -149,43 +87,51 @@
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站点位置</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
<string name="prefs_loc_input_error">输入的位置无效</string>
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站点位置设置</string>
<string name="prefs_station_lat_text">输入站点的纬度</string>
<string name="prefs_station_lon_text">输入站点的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
<string name="prefs_station_title">站位</string>
<string name="prefs_station_lat_text">站位纬度</string>
<string name="prefs_station_lon_text">站位经度</string>
<string name="prefs_locator_title">QTH 定位</string>
<string name="prefs_locator_text">通过 QTH 设置站位</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">自定义URL</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_cat_output">CAT</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">TX 电台蓝牙地址</string>
<string name="rc_rx_device_hint">RX 电台蓝牙地址</string>
<string name="rc_tx_name_hint">TX 电台名称</string>
<string name="rc_rx_name_hint">RX 电台名称</string>
<string name="rc_enable_switch">启用 CAT</string>
<string name="prefs_net_title">网络输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_address_hint">IP:端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
@@ -193,7 +139,7 @@
<string name="prefs_net_frequency_address_hint">IP:端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_title">蓝牙输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
@@ -203,84 +149,26 @@
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_net_perm_error">请检查网络权限</string>
<!-- Radio Control -->
<string name="nav_radiocontrol">电台控制</string>
<string name="rc_settings_title">CAT 电台控制</string>
<string name="rc_radio_model">电台型号</string>
<string name="rc_tx_device_hint">发射电台蓝牙地址</string>
<string name="rc_rx_device_hint">接收电台蓝牙地址</string>
<string name="rc_tx_name_hint">发射电台名称</string>
<string name="rc_rx_name_hint">接收电台名称</string>
<string name="rc_enable_switch">启用 CAT 控制</string>
<string name="tracking_status">跟踪: %s</string>
<string name="loc_lat_text">纬度: %s°</string>
<string name="loc_lon_text">经度: %s°</string>
<string name="loc_qth_text">QTH: %s</string>
<string name="connect_radio">连接</string>
<string name="disconnect_radio">断开</string>
<string name="track_radio">跟踪</string>
<string name="stop_radio">停止</string>
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
<string name="prefs_wavelog_url_hint">服务器地址</string>
<string name="prefs_wavelog_key_hint">API 密钥</string>
<string name="prefs_wavelog_station_hint">站点 ID</string>
<string name="prefs_wavelog_auto">自动上传</string>
<string name="prefs_wavelog_test">测试连接</string>
<string name="prefs_wavelog_update_sats">更新卫星列表</string>
<string name="prefs_wavelog_upload">手动上传</string>
<string name="wavelog_title">日志</string>
<string name="wavelog_select_transponder">选择转发器</string>
<string name="wavelog_call_hint">对方呼号</string>
<string name="wavelog_mode_hint">模式</string>
<string name="wavelog_saved">已存入本地日志</string>
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
<string name="wavelog_empty">暂无日志记录</string>
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
<string name="wavelog_ignore">忽略并上传</string>
<string name="wavelog_cancel">取消</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">在此粘贴 QRZ 登录 Cookie(EditThisCookie 导出 或 "k=v; k=v" 格式)。用于上传时查询对方网格。</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">复制</string>
<string name="wavelog_delete_undo">撤销</string>
<string name="wavelog_col_time">时间</string>
<string name="wavelog_col_freq">频率</string>
<string name="wavelog_col_sat">卫星</string>
<string name="wavelog_col_call">呼号</string>
<string name="wavelog_col_uploaded">已传</string>
<string name="wavelog_ungrouped">未分组</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_ui_title">UI设置</string>
<string name="prefs_ui_order_title">页面顺序</string>
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
<string name="prefs_ui_sub_title">更多菜单</string>
<string name="prefs_ui_move_out">移入更多菜单</string>
<string name="prefs_ui_move_back">移回主菜单</string>
<string name="prefs_ui_order_locked">固定(不可移动)</string>
<string name="prefs_ui_order_reset">重置顺序</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_other_switch_night_mode">启用红色夜间模式</string>
<string name="prefs_other_title">其他</string>
<string name="prefs_other_switch_utc">UTC 时间</string>
<string name="prefs_other_switch_update">自动更新数据</string>
<string name="prefs_other_switch_sweep">雷达扫描动画</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string>
<string name="prefs_highlight_mid">中 %1$d-%2$d°</string>
<string name="prefs_highlight_high">高 &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA 与原作者感谢:</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
+65 -146
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="app_name" translatable="false">Look4Sat</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
@@ -10,71 +10,27 @@
<string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_mutual">Match</string>
<string name="nav_roaming">Roaming</string>
<string name="nav_cw">CW Decoder</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<string name="prefs_aprs_title">APRS Reporting</string>
<string name="prefs_aprs_enable">Enable APRS reporting</string>
<string name="prefs_aprs_summary">Server: %1$s:%2$d Callsign: %3$s</string>
<string name="prefs_aprs_report_now">Report Now</string>
<string name="prefs_aprs_settings_title">APRS Settings</string>
<string name="prefs_aprs_server">Server</string>
<string name="prefs_aprs_port">Port</string>
<string name="prefs_aprs_callsign">Callsign</string>
<string name="prefs_aprs_ssid">SSID</string>
<string name="prefs_aprs_passcode">Passcode</string>
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
<string name="prefs_aprs_interval">Interval (min)</string>
<string name="prefs_aprs_status">Status text</string>
<string name="prefs_aprs_symbol_table">Symbol table</string>
<string name="prefs_aprs_symbol_code">Symbol code</string>
<string name="prefs_aprs_save">Save</string>
<string name="prefs_aprs_cancel">Cancel</string>
<string name="aprs_notif_channel">APRS service</string>
<string name="aprs_notif_title">APRS: %1$s</string>
<string name="aprs_notif_running">Reporting running</string>
<string name="aprs_notif_connected">Connected to APRS-IS</string>
<string name="aprs_notif_error">Connection error</string>
<string name="aprs_notif_stop">Stop</string>
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
<string name="aprs_toast_ok">APRS: report sent OK</string>
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
<string name="prefs_aprs_last_ok">OK</string>
<string name="prefs_aprs_last_fail">failed - %1$s</string>
<string name="nav_more">More</string>
<string name="nav_log">Log</string>
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
<string name="sat_type_title">Select satellite type</string>
<string name="sat_type_hint">Modes: %s</string>
<string name="sat_type_title">Select modes</string>
<string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_group_selected">Selected</string>
<string name="sat_group_available">Available</string>
<string name="sat_group_selected_count">Selected (%1$d)</string>
<string name="sat_group_available_count">Available (%1$d)</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time.
\n\nAlways try to narrow down the list to only the ones
you\'re interested in via search and types selector.</string>
you\'re interested in via search and the modes selector.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filter passes</string>
@@ -84,7 +40,7 @@
<string name="pass_filter_invert_time">Invert AOS window</string>
<string name="pass_filter_deep_space">DeepSpace (period >225min)</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string>
<string name="pass_modes_title">Select modes</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
@@ -98,14 +54,34 @@
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
* NEW: CW decoder now uses the DeepCW neural network — far better weak-signal Morse decoding
* NEW: CW decode history is kept permanently (text no longer disappears)
* CW waterfall: new inferno colour scheme with smooth gradients
* Restored 64-bit (arm64) support
* Fixed: pause/resume no longer shows a spurious "decode failed" error
<string name="pass_whatsnew_message" translatable="false">
* Added small tweaks to Sources and strings. Added official ISS source (#225)
\n* Implemented AOS window and elevation highlight filters (#226)
\n* Added cleartext traffic support for custom TLE URLs (#227)
\n* Fixed manual OMM (.csv) data import, tweaked error messaging (#228)
\n* Added support for Icom IC-705 CAT by ruilvo (#229)
\n* Added configurable Radar offset to the sensors output by qwqtoday (#230)
\n* Localized pass date and time formats for Chinese by atsunatsu (#231)
\n* Added linear transponder Doppler calculator by atsunatsu (#232)
\n* Added a few tweaks to SSTV sensitivity and reception, similar to Robot36
\n* Replaced the types selection dialog with modes selection to avoid confusion
</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT Satellite Status</string>
<string name="amsat_refresh">Refresh</string>
<string name="amsat_retry">Retry</string>
<string name="amsat_updated">Updated:</string>
<string name="amsat_active">Active</string>
<string name="amsat_tlm">TLM/Beacon</string>
<string name="amsat_not_heard">Not Heard</string>
<string name="amsat_conflict">Conflicting</string>
<string name="amsat_name">Name</string>
<string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
@@ -131,18 +107,9 @@
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string>
<string name="radar_doppler_calc">Doppler Frequency Calculator</string>
<string name="radar_doppler_tx_hint">Enter TX freq (MHz)</string>
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
<string name="radar_doppler_info">For linear transponders, type one frequency to see the other</string>
<string name="radar_cw_decoder">CW Decoder</string>
<string name="radar_cw_start">Start</string>
<string name="radar_cw_stop">Stop</string>
<string name="radar_cw_reset">Clear</string>
<string name="radar_cw_tone">Tone %1$d Hz</string>
<string name="radar_cw_waiting">Waiting for signal…</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
@@ -163,7 +130,7 @@
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat Pro v%s</string>
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
@@ -181,35 +148,33 @@
<string name="prefs_loc_title">Station position</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
<string name="prefs_loc_gps_error">Check your location permissions</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Invalid location entered</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_station_title">Station position</string>
<string name="prefs_station_lat_text">Station latitude</string>
<string name="prefs_station_lon_text">Station longitude</string>
<string name="prefs_locator_title">QTH locator</string>
<string name="prefs_locator_text">Set position via locator</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
<string name="prefs_data_update">Update</string>
<string name="prefs_data_import">Custom URL</string>
<string name="prefs_data_import">Import</string>
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_update_failed">Update failed - check your network</string>
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_title">Custom sources</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_reset_url">Reset to default URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
@@ -218,15 +183,15 @@
<!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string>
<string name="rc_settings_title">CAT control</string>
<string name="rc_radio_model">Radio Model</string>
<string name="rc_tx_device_hint">TX Radio BT Address</string>
<string name="rc_rx_device_hint">RX Radio BT Address</string>
<string name="rc_tx_device_hint">TX BT address</string>
<string name="rc_rx_device_hint">RX BT address</string>
<string name="rc_tx_name_hint">TX Radio Name</string>
<string name="rc_rx_name_hint">RX Radio Name</string>
<string name="rc_enable_switch">Enable CAT Control</string>
<string name="rc_enable_switch">Enable CAT</string>
<string name="prefs_net_title">Network data output</string>
<string name="prefs_net_title">Network output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_address_hint">IP:Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
@@ -234,74 +199,31 @@
<string name="prefs_net_frequency_address_hint">IP:Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Bluetooth data output</string>
<string name="prefs_bt_title">Bluetooth output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Device id</string>
<string name="prefs_bt_rotator_output_hint">Data format</string>
<string name="prefs_bt_rotator_device_hint">Device ID</string>
<string name="prefs_bt_rotator_output_hint">Format</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Device id</string>
<string name="prefs_bt_frequency_output_hint">Data format</string>
<string name="prefs_bt_frequency_device_hint">Device ID</string>
<string name="prefs_bt_frequency_output_hint">Format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_net_perm_error">Check your network permission</string>
<string name="tracking_status">Tracking: %s</string>
<string name="loc_lat_text">Lat: %s°</string>
<string name="loc_lon_text">Lon: %s°</string>
<string name="loc_qth_text">Qth: %s</string>
<string name="connect_radio">Connect</string>
<string name="disconnect_radio">Disconnect</string>
<string name="track_radio">Track</string>
<string name="stop_radio">Stop</string>
<string name="prefs_wavelog_title">WaveLog log server</string>
<string name="prefs_wavelog_url_hint">Server address</string>
<string name="prefs_wavelog_key_hint">API key</string>
<string name="prefs_wavelog_station_hint">Station ID</string>
<string name="prefs_wavelog_auto">Auto upload</string>
<string name="prefs_wavelog_test">Test connection</string>
<string name="prefs_wavelog_update_sats">Update sats</string>
<string name="prefs_wavelog_upload">Upload now</string>
<string name="wavelog_title">Log</string>
<string name="wavelog_select_transponder">Select transponder</string>
<string name="wavelog_call_hint">Callsign</string>
<string name="wavelog_mode_hint">Mode</string>
<string name="wavelog_saved">Saved to local log</string>
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
<string name="wavelog_empty">No log entries yet</string>
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
<string name="wavelog_ignore">Ignore and upload</string>
<string name="wavelog_cancel">Cancel</string>
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
<string name="prefs_wavelog_qrz_hint">Paste your QRZ login cookies here (EditThisCookie export or "k=v; k=v" string). Used to fetch the other station grid square for uploads.</string>
<string name="prefs_wavelog_qrz_label">Cookie</string>
<string name="wavelog_copy">Copy</string>
<string name="wavelog_delete_undo">Undo</string>
<string name="wavelog_col_time">Time</string>
<string name="wavelog_col_freq">Freq</string>
<string name="wavelog_col_sat">Satellite</string>
<string name="wavelog_col_call">Call</string>
<string name="wavelog_col_uploaded">Sent</string>
<string name="wavelog_ungrouped">Ungrouped</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_ui_title">UI Settings</string>
<string name="prefs_ui_order_title">Page order</string>
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
<string name="prefs_ui_sub_title">More menu</string>
<string name="prefs_ui_move_out">Move to more menu</string>
<string name="prefs_ui_move_back">Move back to main menu</string>
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
<string name="prefs_ui_order_reset">Reset order</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_other_switch_night_mode">Enable red night mode filter</string>
<string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string>
<string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string>
<string name="prefs_highlight_mid">Mid %1$d–%2$d°</string>
<string name="prefs_highlight_high">High &gt; %1$d°</string>
<string name="prefs_outro_title">BG7NTA &amp; the original author would like to thank</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
* Look4Sat users and contributors!
\n* David A. B. Johnson (predict4java)
@@ -310,10 +232,7 @@
\n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
</string>
<string name="prefs_outro_license">The app comes with no warranty</string>
<string name="prefs_outro_deepcw" translatable="false">CW decoding uses the DeepCW model by e04, licensed under AGPL-3.0-only.\nhttps://github.com/e04/deepcw-engine</string>
</resources>
@@ -1,2 +1,8 @@
* Added required tweaks to support Android 17 (API 37)
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
* Added ARISS source, tweaked SSTV sensitivity #225
* Implemented AOS window and elev filter #226
* Added cleartext traffic support for TLEs #227
* Fixed OMM import, tweaked error messages #228
* Added support for Icom CAT by ruilvo #229
* Added tweakable Radar offset by qwqtoday #230
* Localized date/time formats by atsunatsu #231
* Added linear radio Doppler calc by atsunatsu #232
-96
View File
@@ -1,96 +0,0 @@
# DeepCW Decoder — module documentation
The `feature:cw` module decodes Morse code (CW) with the DeepCW neural model
instead of a classical DSP detector. This document records the architecture,
the model provenance, the pitfalls we hit on real devices, and the verification
results. It exists so the next person does not have to re-derive any of it.
## Architecture
```
microphone (44.1 kHz PCM float)
│ AudioCapture.audioFlow() → ~100 ms chunks
▼
CwDecodeScreen (feature:cw, pure Compose)
│ decoder.processBuffer(chunk) waterfall.pushSamples(chunk)
▼ ▼
CwDeepDecoder (core:data) CwWaterfallState (feature:cw)
resample → 3200 Hz resample → 3200 Hz
CwDeepBuffer (20 s rolling) CwDeepSpectrogram.compute
CwDeepSpectrogram.compute → rolling spectrogram history
ONNX Runtime session.run → Canvas waterfall
CwCtcDecoder.greedy (400–1200 Hz band, same magnitudes
→ decodedText StateFlow the model sees)
```
**Why the split.** `core:domain` must stay pure Kotlin/JVM (KMP migration
headroom, per `AGENTS.md`), so the ONNX Runtime dependency — which ships native
libraries — lives in `core:data` behind the `ICwDecoder` interface. The
spectrogram front-end (`CwDeepSpectrogram`) and CTC collapse (`CwCtcDecoder`)
are pure Kotlin and live in `core:domain`, where they are unit-tested against
the Python reference with golden vectors.
**Streaming model, not incremental.** DeepCW is a whole-utterance CTC model. It
rewrites earlier characters as more context arrives, so incremental appending is
wrong. The decoder keeps a rolling buffer capped at 20 s and re-runs the whole
window every 1.5 s, replacing the displayed text outright. 20 s is the smallest
window that hits 0 % CER on the reference clip *and* the largest that keeps
inference comfortably faster than real time (measured ~14× headroom on a server
CPU; timed per-window on device and reported via `lastInferenceMs`).
## Model
| | fp32 (original) | int8 (shipped) |
|---|---|---|
| Size | 15,139,839 bytes | 4,248,808 bytes |
| Derivation | — | `quantize_dynamic` (weights → QUInt8, activations float32) |
| Input | `spectrogram` [1,1,T,65] float32 | unchanged |
| Output | `log_probs` [1,T,42] float32 | unchanged |
| In APK | no (available as a release asset) | yes (`assets/deepcw/model.onnx`) |
The int8 model ships inside the APK: it is ~4× smaller and measurably identical
to fp32 on synthetic CW at SNR ≥ −4 dB (both degrade together below that). The
fp32 model is published as a separate release asset for anyone who wants the
highest-fidelity reference. Both are AGPL-3.0-only — see
[`licenses/NOTICE.md`](licenses/NOTICE.md) for provenance, commit SHA and hashes.
Audio must be packaged **uncompressed** (`noCompress += "onnx"` in the app
module): ONNX Runtime mmap's assets and refuses compressed ones.
## Pitfalls fixed on real devices (release-only)
1. **R8 minification breaks the JNI binding.** `isMinifyEnabled = true` in the
convention plugin, but `onnxruntime-android` ships **no** consumer ProGuard
rules, so `ai.onnxruntime.*` got renamed and native code crashed with no Java
stack trace. Fixed with
`-keep class ai.onnxruntime.** { *; }` in `app/proguard-rules.pro`.
Symptom was the exact "flash to home screen, empty crash log" we chased.
2. **Model load in `init{}`** took down the whole composable on failure; moved
to lazy `ensureLoaded()` with an `errorMessage` path.
3. **Default thread count** saturated all cores and starved audio capture/UI;
capped `setIntraOpNumThreads` to `cores−1` (max 4).
4. **Compose snapshot state written from the audio thread** crashes at runtime
with no log from business-class instrumentation; the waterfall now uses a
`MutableStateFlow` revision counter, and both the row deque and the pending
buffer are lock-guarded.
5. **`LaunchedEffect { launch { collect() } }`** leaked a second `AudioRecord`
when toggling; collection now runs directly in the effect body keyed on
`(isListening, permissionGranted)`.
Diagnostics (no-adb): `MainApplication` already writes uncaught exceptions to
`files/crash_log.txt`. The decoder additionally persists load/inference failures
to `files/deepcw_load_error.txt` / `files/deepcw_infer_error.txt` and step
markers to `files/probe_cw.txt` (`load_begin → load_session_ok → infer_begin →
infer_done`).
## Verification
- **Golden vectors**: `CwDeepSpectrogramTest` + `CwDeepGoldenVectorTest` pin the
Kotlin spectrogram to the Python reference across 5005 values (tolerance
1e-4). 79 domain tests pass.
- **End-to-end on server**: real 20 s noisy CW audio → resample → spectrogram →
ONNX → greedy CTC reproduces `CQ CQ DE BG7NTA BG7NTA K 5NN TU 73`
character-for-character (~1 s inference, fp32).
- **On device (release APK)**: decodes 40 WPM CW at high accuracy. Some UI
latency is expected on the first decode cycle (model load + first inference);
sustained decoding stays real-time.
-22
View File
@@ -1,22 +0,0 @@
import com.android.build.api.variant.CanProduceConsumerProguardFiles
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.cw"
}
androidComponents {
onVariants(selector().all()) { variant ->
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
project.layout.projectDirectory.file("proguard-rules.pro")
)
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:data"))
}
-661
View File
@@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
-78
View File
@@ -1,78 +0,0 @@
# Third-Party Notices — CW Decode Module
## DeepCW neural CW decoding model
The CW (Morse code) decoder in this module performs inference with a neural
network model obtained from the DeepCW project.
| | |
|---|---|
| **Component** | `src/main/assets/deepcw/model.onnx` and `model.onnx.json` |
| **Upstream project** | DeepCW / deepcw-engine |
| **Source repository** | https://github.com/e04/deepcw-engine |
| **Author / copyright** | e04 |
| **License** | GNU Affero General Public License v3.0 only (AGPL-3.0-only) |
| **License text** | [`DeepCW-AGPL-3.0.txt`](DeepCW-AGPL-3.0.txt) |
| **Obtained at commit** | `8e264d243bbd4467bd19f3f28292219405b47e0e` |
| **Original file size** | 15,139,839 bytes |
| **Original SHA-256** | `ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02` |
| **Derived file size** | 4,248,808 bytes |
| **Derived SHA-256** | `cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98` |
| **Derivation** | Dynamic int8 quantization (weights → QUInt8, activations stay float32) via `onnxruntime.quantization.quantize_dynamic`. Input/output names, shapes and dtypes are unchanged. Measured CER on synthetic CW audio is identical to the fp32 model at SNR >= -4 dB; at -6/-8 dB both models degrade similarly. |
Related upstream repositories by the same author (not vendored here):
- https://github.com/e04/web-deep-cw-decoder — reference web application
- https://github.com/e04/HamNoise — neural noise reduction (not used)
### License compatibility
Look4Sat is licensed under the GNU General Public License v3.0 or later
(GPL-3.0-or-later). The DeepCW model is licensed under AGPL-3.0-only.
Section 13 of the GPL version 3 expressly permits combining GPL-3.0 covered
work with AGPL-3.0 covered work; the resulting combination may be conveyed,
with the AGPL's additional network-interaction requirement applying to the
AGPL-covered portion. Accordingly:
- The Look4Sat source code remains under GPL-3.0-or-later.
- The DeepCW model remains under AGPL-3.0-only.
- Distributions of the combined application are accompanied by complete
corresponding source, satisfying both licenses.
### AGPL section 13 (network interaction)
Inference runs entirely on the local device via ONNX Runtime. The application
does not offer the model's functionality to users interacting with it remotely
over a network, so the additional network-source-offer requirement of AGPL-3.0
section 13 is not triggered by this usage. The complete corresponding source
for both the application and the vendored model remains publicly available at
the repository hosting this file.
### Reproducing the vendored files
```bash
# 1) Fetch the original fp32 model
SHA=8e264d243bbd4467bd19f3f28292219405b47e0e
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx
curl -sLO https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/model.onnx.json
curl -sL -o DeepCW-AGPL-3.0.txt \
https://raw.githubusercontent.com/e04/deepcw-engine/$SHA/LICENSE
sha256sum model.onnx
# expected: ef120799457bca042d4690944f0faf93268eb4654e7f50f28784ad63bdc1fe02
# 2) Reproduce the int8 quantization this repository ships
python - <<'PY'
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic("model.onnx", "model_int8.onnx", weight_type=QuantType.QUInt8)
PY
sha256sum model_int8.onnx
# expected: cd48259be0ea8c30ecbfff4a718644f361cb27b9228b030771b0c94756dcab98
# then copy model_int8.onnx over assets/deepcw/model.onnx
```
## ONNX Runtime
Inference engine: `com.microsoft.onnxruntime:onnxruntime-android`, licensed
under the MIT License. Consumed as a published Maven artifact; not vendored in
this repository.
-2
View File
@@ -1,2 +0,0 @@
# CW 模块无 native 代码、无按类名注册的 JNI,因此不需要任何 keep 规则。
# DeepCW 走 ONNX Runtime(自带 consumer proguard 规则),前后处理为普通 Kotlin。
Binary file not shown.
@@ -1,70 +0,0 @@
{
"chars": [
",",
".",
"/",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"?",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
" "
],
"blank_index": 41,
"sample_rate": 3200,
"fft_length": 256,
"hop_length": 48,
"spectrogram_min_freq_hz": 400.0,
"spectrogram_max_freq_hz": 1200.0,
"spectrogram_frequency_bins": 65,
"normalization": "log1p",
"onnx_input_name": "spectrogram",
"onnx_output_name": "log_probs",
"onnx_input_layout": [
"batch",
"channel",
"time",
"frequency"
],
"onnx_output_layout": [
"batch",
"time",
"class"
],
"channel_count": 1,
"num_classes": 42,
"onnx_input_dtype": "float32",
"onnx_output_dtype": "float32"
}
@@ -1,278 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R as CoreR
/**
* Full-page CW decoder backed by DeepCW.
*
* Layout follows the DeepCW reference app: waterfall on top, the live line
* under it, then the scrolling history. Pause/clear controls and the microphone
* permission flow carry over from the previous page.
*
* There is no settings dialog any more: the model analyses a fixed
* 400-1200 Hz window and tracks speed on its own, so there is nothing to tune.
*/
@Composable
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
val context = LocalContext.current
val container = remember { (context.applicationContext as IContainerProvider).getMainContainer() }
val decoder = remember { container.provideCwDecoder() }
val audioCapture = remember { container.provideAudioCapture() }
val waterfall = remember { CwWaterfallState() }
var permissionGranted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
var permanentlyDenied by remember { mutableStateOf(false) }
var isListening by remember { mutableStateOf(false) }
val decodedText by decoder.decodedText.collectAsState()
val historyText by decoder.historyText.collectAsState()
val estimatedPitch by decoder.estimatedPitch.collectAsState()
val signalStrength by decoder.signalStrength.collectAsState()
val inferenceMs by decoder.lastInferenceMs.collectAsState()
val errorMessage by decoder.errorMessage.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionGranted = granted
val activity = context as? Activity
permanentlyDenied = !granted && activity != null &&
!activity.shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)
if (granted) isListening = true
}
// Capture runs only while listening. Keyed on both listening state and
// permission so granting the permission mid-flow restarts collection
// (isListening may already be true when the launcher returns).
LaunchedEffect(isListening, permissionGranted) {
if (!isListening) return@LaunchedEffect
if (!permissionGranted) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return@LaunchedEffect
}
audioCapture.audioFlow().collect { chunk ->
decoder.processBuffer(chunk, audioCapture.sampleRate)
waterfall.pushSamples(chunk, audioCapture.sampleRate)
}
}
DisposableEffect(Unit) {
onDispose {
// OrtSession holds native memory and must be released explicitly.
decoder.close()
}
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { isListening = false; navigateUp() }) {
Icon(
painter = painterResource(CoreR.drawable.ic_back),
contentDescription = stringResource(R.string.cw_back)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(CoreR.string.nav_cw),
style = MaterialTheme.typography.titleMedium
)
Text(
text = if (estimatedPitch != null) {
stringResource(R.string.cw_status_tone, estimatedPitch!!.toInt(), inferenceMs)
} else {
stringResource(R.string.cw_status_listening)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { isListening = !isListening }) {
Icon(
painter = painterResource(
if (isListening) CoreR.drawable.ic_pause else CoreR.drawable.ic_play
),
contentDescription = stringResource(
if (isListening) R.string.cw_pause else R.string.cw_resume
)
)
}
IconButton(onClick = { decoder.reset(); waterfall.clear() }) {
Icon(
painter = painterResource(CoreR.drawable.ic_delete),
contentDescription = stringResource(R.string.cw_clear)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.padding(horizontal = 8.dp)
.clip(RoundedCornerShape(8.dp))
) {
CwWaterfallView(state = waterfall, signalStrength = signalStrength)
}
Text(
text = decodedText.takeLast(64).ifEmpty { "…" },
fontSize = 20.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
) {
Text(
text = (historyText + decodedText).ifEmpty { "…" },
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
errorMessage?.let { message ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center
) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
if (!permissionGranted) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(R.string.cw_mic_permission),
color = MaterialTheme.colorScheme.inverseOnSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
)
Button(onClick = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) {
Text(stringResource(R.string.cw_grant_permission))
}
if (permanentlyDenied) {
TextButton(onClick = {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null)
)
)
}) {
Text(stringResource(R.string.cw_open_settings))
}
}
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@@ -1,182 +0,0 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.feature.cw
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Rolling spectrogram history for the waterfall display.
*
* Reuses [CwDeepSpectrogram] so the picture shows exactly the 400-1200 Hz band
* and the same magnitudes the model sees — what you look at is what it decodes.
*/
class CwWaterfallState(private val historyRows: Int = 96) {
// pushSamples runs on the audio capture thread while snapshot() runs on the
// Compose draw thread, so every touch of these two collections is guarded.
// ArrayDeque is not thread-safe: concurrent removeFirst()/toList() throws.
private val lock = Any()
private val rows = ArrayDeque<FloatArray>(historyRows)
private val pending = ArrayList<Float>(CwDeepSpectrogram.SAMPLE_RATE)
/**
* Bumped on every change so Compose knows to redraw.
*
* A StateFlow, not `mutableIntStateOf`: this is written from the audio
* capture thread, and Compose snapshot state must only be mutated from the
* composition thread — doing otherwise crashes at runtime.
*/
private val _revision = MutableStateFlow(0)
val revision: StateFlow<Int> = _revision.asStateFlow()
/** Snapshot for drawing, oldest row first. */
fun snapshot(): List<FloatArray> = synchronized(lock) { rows.toList() }
fun pushSamples(chunk: FloatArray, sampleRate: Int) {
if (chunk.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
chunk, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val audio: FloatArray
synchronized(lock) {
pending.ensureCapacity(pending.size + resampled.size)
for (sample in resampled) pending.add(sample)
// Need at least one FFT window before a row can be produced.
if (pending.size < CwDeepSpectrogram.FFT_LENGTH) return
audio = FloatArray(pending.size) { pending[it] }
pending.clear()
}
// FFT outside the lock; only the append below needs exclusivity.
val computed = CwDeepSpectrogram.compute(audio)
synchronized(lock) {
for (row in computed) {
if (rows.size >= historyRows) rows.removeFirst()
rows.addLast(row)
}
}
_revision.value += 1
}
fun clear() {
synchronized(lock) {
rows.clear()
pending.clear()
}
_revision.value += 1
}
}
/**
* Draws the waterfall newest-row-last, one pixel column per frequency bin.
* Colour ramp is the inferno palette (black -> purple -> orange -> yellow).
*/
@Composable
internal fun CwWaterfallView(
state: CwWaterfallState,
signalStrength: Float,
modifier: Modifier = Modifier
) {
val revision by state.revision.collectAsState()
Canvas(modifier = modifier.fillMaxSize()) {
// Touch the revision inside the draw scope so a new spectrum triggers a
// redraw; without this read the canvas would only ever render once.
@Suppress("UNUSED_EXPRESSION") revision
drawRect(color = Color(0xFF00060F), size = size)
val rows = state.snapshot()
if (rows.isEmpty()) return@Canvas
// Scale to the loudest value on screen so quiet signals stay visible.
var peak = 0f
for (row in rows) for (v in row) if (v > peak) peak = v
if (peak <= 0f) return@Canvas
val rowHeight = size.height / rows.size
val binWidth = size.width / CwDeepSpectrogram.FREQUENCY_BINS
for ((index, row) in rows.withIndex()) {
val y = index * rowHeight
// Linear interpolation between adjacent bins via a horizontal
// gradient removes the blocky "pixel" look of 65 discrete columns.
for (bin in 0 until row.size - 1) {
val m0 = (row[bin] / peak).coerceIn(0f, 1f)
val m1 = (row[bin + 1] / peak).coerceIn(0f, 1f)
if (m0 < 0.06f && m1 < 0.06f) continue
drawRect(
brush = Brush.horizontalGradient(listOf(inferno(m0), inferno(m1))),
topLeft = Offset(bin * binWidth, y),
size = Size(binWidth + 1f, rowHeight + 1f)
)
}
}
if (signalStrength > 0f) {
drawRect(
color = Color(0xFF4CD964).copy(alpha = 0.8f),
topLeft = Offset(0f, size.height - 3f),
size = Size(size.width * signalStrength.coerceIn(0f, 1f), 3f)
)
}
}
}
/**
* matplotlib "inferno" colour map, approximated with piecewise-linear stops
* (black -> purple -> magenta-red -> orange -> pale yellow). The same palette
* used for the static spectrogram illustration, kept for visual consistency.
*/
private val INFERNO_STOPS = arrayOf(
floatArrayOf(0.00f, 0.000f, 0.000f, 0.016f), // black
floatArrayOf(0.25f, 0.231f, 0.059f, 0.439f), // deep purple
floatArrayOf(0.50f, 0.549f, 0.161f, 0.506f), // magenta
floatArrayOf(0.75f, 0.871f, 0.286f, 0.408f), // red-orange
floatArrayOf(1.00f, 0.988f, 1.000f, 0.643f) // pale yellow
)
private fun inferno(t: Float): Color {
val x = t.coerceIn(0f, 1f)
for (i in 0 until INFERNO_STOPS.size - 1) {
val a = INFERNO_STOPS[i]
val b = INFERNO_STOPS[i + 1]
if (x <= b[0]) {
val f = (x - a[0]) / (b[0] - a[0])
return Color(
red = a[1] + (b[1] - a[1]) * f,
green = a[2] + (b[2] - a[2]) * f,
blue = a[3] + (b[3] - a[3]) * f
)
}
}
return Color(0.988f, 1.0f, 0.643f)
}
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Kembali</string>
<string name="cw_pause">Jeda pendekodean</string>
<string name="cw_resume">Lanjutkan pendekodean</string>
<string name="cw_clear">Hapus teks hasil dekode</string>
<string name="cw_status_listening">Mendengarkan…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk pendekodean CW</string>
<string name="cw_grant_permission">Berikan izin</string>
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
</resources>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Geri</string>
<string name="cw_pause">Çözmeyi duraklat</string>
<string name="cw_resume">Çözmeyi sürdür</string>
<string name="cw_clear">Çözülen metni temizle</string>
<string name="cw_status_listening">Dinleniyor…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">CW çözümü için mikrofon izni gerekli</string>
<string name="cw_grant_permission">İzin ver</string>
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
</resources>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">返回</string>
<string name="cw_pause">暂停解码</string>
<string name="cw_resume">继续解码</string>
<string name="cw_clear">清空解码文本</string>
<string name="cw_status_listening">正在监听…</string>
<string name="cw_status_tone">%1$d Hz · %2$d 毫秒</string>
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
<string name="cw_grant_permission">授予权限</string>
<string name="cw_open_settings">打开应用设置</string>
</resources>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="cw_back">Back</string>
<string name="cw_pause">Pause decoding</string>
<string name="cw_resume">Resume decoding</string>
<string name="cw_clear">Clear decoded text</string>
<string name="cw_status_listening">Listening…</string>
<string name="cw_status_tone">%1$d Hz · %2$d ms</string>
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
<string name="cw_grant_permission">Grant permission</string>
<string name="cw_open_settings">Open app settings</string>
</resources>
-7
View File
@@ -1,7 +0,0 @@
plugins {
alias(libs.plugins.convention.featurePlugin)
}
android {
namespace = "com.rtbishop.look4sat.feature.mutual"
}
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more