mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-03 11:17:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf23d53ee8 | ||
|
|
b17ea2d918 | ||
|
|
976fdfd949 | ||
|
|
d8db258bd8 | ||
|
|
7b6fb5eb8f | ||
|
|
f4aef4f4f0 | ||
|
|
9147323db2 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
# GitHub Copilot Instructions
|
||||||
|
|
||||||
|
Read `AGENTS.md` first, then `CLAUDE.md`.
|
||||||
|
|
||||||
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
@@ -16,13 +16,13 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v6
|
uses: gradle/actions/setup-gradle@v6
|
||||||
@@ -58,7 +58,7 @@ jobs:
|
|||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@v1
|
uses: ruby/setup-ruby@v1
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.4'
|
||||||
|
|
||||||
- name: Deploy Bundle to Google Play
|
- name: Deploy Bundle to Google Play
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Look4Sat AI Agent Instructions
|
||||||
|
|
||||||
|
This is the canonical project guide for all AI assistants working on Look4Sat.
|
||||||
|
All assistant-specific files (`CLAUDE.md`, `.github/copilot-instructions.md`) point here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**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()`
|
||||||
|
- Jetpack Compose UI observes state and recomposes reactively
|
||||||
|
|
||||||
|
**Clean Architecture layers:**
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|----------------------|---------------------------------------------------------------------|
|
||||||
|
| `app` | Entry point. Aggregates all modules |
|
||||||
|
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
||||||
|
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
||||||
|
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
||||||
|
| `feature:map` | OSMDroid map with ground tracks |
|
||||||
|
| `feature:passes` | Pass predictions and upcoming events |
|
||||||
|
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```shell
|
||||||
|
# Debug build
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Release build (minified, shrunk resources)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
||||||
|
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
||||||
|
|
||||||
|
## Key Libraries
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **OSMDroid** for map rendering
|
||||||
|
- **Kotlin Serialization** for navigation args and data 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).
|
||||||
|
|
||||||
|
## Data Formats & Migration
|
||||||
|
|
||||||
|
**TLE vs. OMM/CSV format:**
|
||||||
|
|
||||||
|
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Copilot Working Mode: Code-Only
|
||||||
|
|
||||||
|
- Default to code changes only. Provide explanations in chat only.
|
||||||
|
- If documentation seems useful, ask first before creating files.
|
||||||
|
- Do NOT create any `.md` documentation files unless explicitly requested.
|
||||||
|
- Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
|
||||||
|
- Prefer minimal diffs focused on requested implementation.
|
||||||
|
- Default validation is static checks (`get_errors`). Do NOT run Gradle compile/test tasks unless explicitly requested.
|
||||||
@@ -1,110 +1,5 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
## Project Overview
|
Read `AGENTS.md` first, then follow the instructions there.
|
||||||
|
|
||||||
Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active
|
`AGENTS.md` contains the architecture, module boundaries, implementation details, conventions, and gotchas.
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
**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()`
|
|
||||||
- Jetpack Compose UI observes state and recomposes reactively
|
|
||||||
|
|
||||||
**Clean Architecture layers:**
|
|
||||||
|
|
||||||
| ------------------------ | --------------------------------------------------------------------- |
|
|
||||||
| Module | Responsibility |
|
|
||||||
|--------------------------|-----------------------------------------------------------------------|
|
|
||||||
| `app` | Entry point. Aggregates all modules |
|
|
||||||
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
|
|
||||||
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
|
|
||||||
| `core:presentation` | Android library. Compose theme, shared UI components, NavKeys |
|
|
||||||
| `feature:map` | OSMDroid map with ground tracks |
|
|
||||||
| `feature:passes` | Pass predictions and upcoming events |
|
|
||||||
| `feature:radar` | Polar radar view of satellite positions, SSTV image decoding |
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
```shell
|
|
||||||
# Debug build
|
|
||||||
./gradlew assembleDebug
|
|
||||||
|
|
||||||
# Release build (minified, shrunk resources)
|
|
||||||
./gradlew assembleRelease
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
|
|
||||||
- **Gradle**: Uses version catalog (`gradle/libs.versions.toml`) + convention plugins in `build-logic/`
|
|
||||||
|
|
||||||
## Key Libraries
|
|
||||||
|
|
||||||
- **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
|
|
||||||
- **OSMDroid** for map rendering
|
|
||||||
- **Kotlin Serialization** for navigation args and data 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).
|
|
||||||
|
|
||||||
## Data Formats & Migration
|
|
||||||
|
|
||||||
**TLE vs. OMM/CSV format:**
|
|
||||||
|
|
||||||
Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) formats for backward compatibility:
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- 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.
|
|
||||||
@@ -33,3 +33,13 @@ It is now and always will be completely ad-free and open-source.
|
|||||||
* Showing the satellite positional data, footprint and ground track on the map
|
* Showing the satellite positional data, footprint and ground track on the map
|
||||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
* 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.
|
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
-keep class org.osmdroid.** { *; }
|
||||||
|
-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
|
||||||
|
-dontwarn org.osmdroid.**
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@@ -14,15 +15,15 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name="cn.bh2vsq.look4sat.MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true">
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name="cn.bh2vsq.look4sat.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat" />
|
android:value="cn.bh2vsq.look4sat" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package cn.bh2vsq.look4sat
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package cn.bh2vsq.look4sat
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
* You should have received a copy of the GNU 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package cn.bh2vsq.look4sat
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">tianditu.gov.cn</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
+1
-1
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
|
|||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean", Delete::class.java) {
|
tasks.register("clean", Delete::class.java) {
|
||||||
|
description = "Cleans the build directory"
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
|
|||||||
+7
-4
@@ -17,6 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
|
||||||
object Ft817CatProtocol {
|
object Ft817CatProtocol {
|
||||||
|
|
||||||
const val CMD_SET_FREQ: Byte = 0x01
|
const val CMD_SET_FREQ: Byte = 0x01
|
||||||
@@ -50,7 +53,7 @@ object Ft817CatProtocol {
|
|||||||
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
fun encodeFrequencyBcd(frequencyHz: Long): ByteArray {
|
||||||
val freq10Hz = frequencyHz / 10
|
val freq10Hz = frequencyHz / 10
|
||||||
val bcd = ByteArray(4)
|
val bcd = ByteArray(4)
|
||||||
val digits = String.format("%08d", freq10Hz)
|
val digits = String.format(Locale.US, "%08d", freq10Hz)
|
||||||
for (i in 0 until 4) {
|
for (i in 0 until 4) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
val low = digits[i * 2 + 1] - '0'
|
val low = digits[i * 2 + 1] - '0'
|
||||||
@@ -78,8 +81,8 @@ object Ft817CatProtocol {
|
|||||||
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
* 67.0 Hz → 670 (in 0.1 Hz) → BCD [0x06, 0x70]
|
||||||
*/
|
*/
|
||||||
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
fun encodeCtcssToneBcd(toneHz: Double): ByteArray {
|
||||||
val tone01Hz = (toneHz * 10).toLong()
|
val tone01Hz = (toneHz * 10).roundToLong()
|
||||||
val digits = String.format("%04d", tone01Hz)
|
val digits = String.format(Locale.US, "%04d", tone01Hz)
|
||||||
val bcd = ByteArray(2)
|
val bcd = ByteArray(2)
|
||||||
for (i in 0 until 2) {
|
for (i in 0 until 2) {
|
||||||
val high = digits[i * 2] - '0'
|
val high = digits[i * 2] - '0'
|
||||||
@@ -95,7 +98,7 @@ object Ft817CatProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun buildSetModeCommand(mode: String): ByteArray? {
|
fun buildSetModeCommand(mode: String): ByteArray? {
|
||||||
val modeByte = MODE_TO_BYTE[mode.uppercase()] ?: return null
|
val modeByte = MODE_TO_BYTE[mode.uppercase(Locale.US)] ?: return null
|
||||||
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
return byteArrayOf(modeByte, 0x00, 0x00, 0x00, CMD_SET_MODE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-30
@@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class Ft817Controller(
|
class Ft817Controller(
|
||||||
private val bluetoothManager: BluetoothManager,
|
private val bluetoothManager: BluetoothManager,
|
||||||
@@ -39,10 +40,12 @@ class Ft817Controller(
|
|||||||
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
private val sppId: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
|
||||||
private val ioMutex = Mutex()
|
private val ioMutex = Mutex()
|
||||||
private val commandDelayMs = 200L
|
private val commandDelayMs = 200L
|
||||||
|
private val maxAckReadFailures = 3
|
||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
private var outputStream: OutputStream? = null
|
private var outputStream: OutputStream? = null
|
||||||
private var inputStream: InputStream? = null
|
private var inputStream: InputStream? = null
|
||||||
|
private var ackReadFailureCount = 0
|
||||||
|
|
||||||
override var isConnected: Boolean = false
|
override var isConnected: Boolean = false
|
||||||
private set
|
private set
|
||||||
@@ -57,6 +60,7 @@ class Ft817Controller(
|
|||||||
socket = btSocket
|
socket = btSocket
|
||||||
outputStream = btSocket.outputStream
|
outputStream = btSocket.outputStream
|
||||||
inputStream = btSocket.inputStream
|
inputStream = btSocket.inputStream
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = true
|
isConnected = true
|
||||||
Log.i(tag, "Connected to $deviceAddress")
|
Log.i(tag, "Connected to $deviceAddress")
|
||||||
true
|
true
|
||||||
@@ -79,6 +83,7 @@ class Ft817Controller(
|
|||||||
inputStream = null
|
inputStream = null
|
||||||
outputStream = null
|
outputStream = null
|
||||||
socket = null
|
socket = null
|
||||||
|
ackReadFailureCount = 0
|
||||||
isConnected = false
|
isConnected = false
|
||||||
Log.i(tag, "Disconnected from $deviceAddress")
|
Log.i(tag, "Disconnected from $deviceAddress")
|
||||||
}
|
}
|
||||||
@@ -112,8 +117,8 @@ class Ft817Controller(
|
|||||||
ioMutex.withLock {
|
ioMutex.withLock {
|
||||||
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
val sent = sendCommand(Ft817CatProtocol.buildReadFreqModeCommand())
|
||||||
if (!sent) return@withContext null
|
if (!sent) return@withContext null
|
||||||
delay(commandDelayMs)
|
delay(commandDelayMs.milliseconds)
|
||||||
val response = readResponse(5) ?: return@withContext null
|
val response = readResponse() ?: return@withContext null
|
||||||
Ft817CatProtocol.parseReadResponse(response)
|
Ft817CatProtocol.parseReadResponse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,44 +132,76 @@ class Ft817Controller(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
private suspend fun sendCommand(bytes: ByteArray): Boolean {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
outputStream?.write(bytes) ?: return false
|
try {
|
||||||
outputStream?.flush()
|
outputStream?.write(bytes) ?: return@withContext false
|
||||||
delay(commandDelayMs)
|
outputStream?.flush()
|
||||||
true
|
delay(commandDelayMs.milliseconds)
|
||||||
} catch (e: Exception) {
|
true
|
||||||
Log.e(tag, "Send error: ${e.message}")
|
} catch (e: Exception) {
|
||||||
isConnected = false
|
Log.e(tag, "Send error: ${e.message}")
|
||||||
false
|
isConnected = false
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
/** Send command and read the 1-byte ACK response (0x00 = OK). */
|
||||||
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
private suspend fun sendCommandWithAck(bytes: ByteArray): Boolean {
|
||||||
if (!sendCommand(bytes)) return false
|
if (!sendCommand(bytes)) return false
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val ack = inputStream?.read() ?: return false
|
try {
|
||||||
ack == 0x00
|
val ack = inputStream?.read() ?: run {
|
||||||
} catch (e: Exception) {
|
ackReadFailureCount = 0
|
||||||
Log.e(tag, "ACK read error: ${e.message}")
|
isConnected = false
|
||||||
true // command was sent, ACK read failed - continue anyway
|
return@withContext false
|
||||||
|
}
|
||||||
|
if (ack < 0) {
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
Log.i(tag, "ACK stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
ackReadFailureCount = 0
|
||||||
|
ack == 0x00
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ackReadFailureCount += 1
|
||||||
|
Log.w(tag, "ACK read error (${ackReadFailureCount}/$maxAckReadFailures): ${e.message}")
|
||||||
|
if (ackReadFailureCount >= maxAckReadFailures) {
|
||||||
|
Log.e(tag, "Too many ACK read errors, marking radio disconnected")
|
||||||
|
isConnected = false
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true // Command was sent, treat transient ACK read failures as best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readResponse(length: Int): ByteArray? {
|
private suspend fun readResponse(): ByteArray? {
|
||||||
return try {
|
return withContext(Dispatchers.IO) {
|
||||||
val buffer = ByteArray(length)
|
try {
|
||||||
var read = 0
|
val responseSize = 5
|
||||||
while (read < length) {
|
val buffer = ByteArray(responseSize)
|
||||||
val count = inputStream?.read(buffer, read, length - read) ?: return null
|
var read = 0
|
||||||
if (count < 0) return null
|
while (read < responseSize) {
|
||||||
read += count
|
val count = inputStream?.read(buffer, read, responseSize - read) ?: run {
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
Log.i(tag, "Response stream closed by remote device")
|
||||||
|
isConnected = false
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
read += count
|
||||||
|
}
|
||||||
|
buffer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Read error: ${e.message}")
|
||||||
|
isConnected = false
|
||||||
|
null
|
||||||
}
|
}
|
||||||
buffer
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(tag, "Read error: ${e.message}")
|
|
||||||
isConnected = false
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,9 @@ package com.rtbishop.look4sat.core.data.injection
|
|||||||
|
|
||||||
import android.bluetooth.BluetoothManager
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
@@ -120,9 +119,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSensorsRepo(): ISensorsRepo {
|
override fun provideSensorsRepo(): ISensorsRepo {
|
||||||
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
val window = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
return SensorsRepo(manager, displayManager)
|
||||||
return SensorsRepo(manager,sensor,window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.view.Display
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.WindowManager
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
@@ -31,13 +32,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.math.round
|
import kotlin.math.round
|
||||||
|
|
||||||
|
private const val SMOOTHING_FACTOR = 0.15f
|
||||||
|
private const val SENSOR_RATE_US = 16_000
|
||||||
|
|
||||||
class SensorsRepo(
|
class SensorsRepo(
|
||||||
private val sensorManager: SensorManager,
|
private val sensorManager: SensorManager,
|
||||||
private val sensor: Sensor?,
|
private val displayManager: DisplayManager?
|
||||||
private val windowManager: WindowManager
|
) : ISensorsRepo, SensorEventListener {
|
||||||
) : SensorEventListener, ISensorsRepo {
|
|
||||||
|
|
||||||
private val _orientation = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
|
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -45,11 +49,7 @@ class SensorsRepo(
|
|||||||
private var smoothPitch = 0f
|
private var smoothPitch = 0f
|
||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
companion object {
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
private const val SMOOTHING_FACTOR = 0.15f
|
|
||||||
}
|
|
||||||
|
|
||||||
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
|
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,7 +62,7 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, 8000) }
|
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
@@ -70,16 +70,11 @@ class SensorsRepo(
|
|||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor == sensor) updateOrientation(event.values)
|
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
return try {
|
return displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
windowManager.defaultDisplay.rotation
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Surface.ROTATION_0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun remapForRotation(rotation: Int) {
|
private fun remapForRotation(rotation: Int) {
|
||||||
@@ -101,30 +96,23 @@ class SensorsRepo(
|
|||||||
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
if (remapped) System.arraycopy(tempMatrix, 0, rotationMatrix, 0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateOrientation(rotationVector: FloatArray) {
|
private fun handleSensorEvent(event: SensorEvent) {
|
||||||
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
|
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
|
||||||
remapForRotation(getDisplayRotation())
|
remapForRotation(getDisplayRotation())
|
||||||
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
SensorManager.getOrientation(rotationMatrix, orientationValues)
|
||||||
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
|
val azimuth = normalizeAzimuth((orientationValues[0] * RAD2DEG).toFloat())
|
||||||
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
val pitch = (orientationValues[1] * RAD2DEG).toFloat()
|
||||||
val magneticAzimuth = (azimuth + 360f) % 360f
|
|
||||||
|
|
||||||
if (!hasInitialReading) {
|
if (!hasInitialReading) {
|
||||||
smoothAzimuth = magneticAzimuth
|
smoothAzimuth = azimuth
|
||||||
smoothPitch = pitch
|
smoothPitch = pitch
|
||||||
hasInitialReading = true
|
hasInitialReading = true
|
||||||
} else {
|
} else {
|
||||||
smoothAzimuth = lowPassAngle(smoothAzimuth, magneticAzimuth)
|
smoothAzimuth = lowPassAngle(smoothAzimuth, azimuth)
|
||||||
smoothPitch = lowPass(smoothPitch, pitch)
|
smoothPitch = lowPass(smoothPitch, pitch)
|
||||||
}
|
}
|
||||||
|
_sensorData.value = Pair(round(smoothAzimuth * 10) / 10, round(smoothPitch * 10) / 10)
|
||||||
_orientation.value = Pair(
|
|
||||||
round(smoothAzimuth * 10) / 10,
|
|
||||||
round(smoothPitch * 10) / 10
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Standard exponential low-pass filter. */
|
|
||||||
private fun lowPass(previous: Float, current: Float): Float {
|
private fun lowPass(previous: Float, current: Float): Float {
|
||||||
return previous + SMOOTHING_FACTOR * (current - previous)
|
return previous + SMOOTHING_FACTOR * (current - previous)
|
||||||
}
|
}
|
||||||
@@ -135,9 +123,10 @@ class SensorsRepo(
|
|||||||
*/
|
*/
|
||||||
private fun lowPassAngle(previous: Float, current: Float): Float {
|
private fun lowPassAngle(previous: Float, current: Float): Float {
|
||||||
var delta = current - previous
|
var delta = current - previous
|
||||||
// Normalise delta into the range (-180, 180]
|
|
||||||
while (delta > 180f) delta -= 360f
|
while (delta > 180f) delta -= 360f
|
||||||
while (delta <= -180f) delta += 360f
|
while (delta <= -180f) delta += 360f
|
||||||
return (previous + SMOOTHING_FACTOR * delta + 360f) % 360f
|
return normalizeAzimuth(previous + SMOOTHING_FACTOR * delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeAzimuth(value: Float): Float = (value + 360f) % 360f
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
@@ -82,6 +83,8 @@ class SettingsRepo(
|
|||||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
|
private val keyMapSource = "mapSource"
|
||||||
|
private val keyTiandituKey = "tiandituKey"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
@@ -113,8 +116,8 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getSelectedTypes(): List<String> {
|
private fun getSelectedTypes(): List<String> {
|
||||||
val typesString = preferences.getString(keySelectedTypes, null)
|
val typesString = preferences.getString(keySelectedTypes, "Amateur")
|
||||||
if (typesString.isNullOrEmpty()) return listOf("Amateur")
|
if (typesString.isNullOrEmpty()) return emptyList()
|
||||||
return typesString.split(separatorComma)
|
return typesString.split(separatorComma)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -335,6 +338,8 @@ class SettingsRepo(
|
|||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
|
putString(keyMapSource, MapSource.normalize(new.mapSource))
|
||||||
|
putString(keyTiandituKey, new.tiandituKey)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -349,7 +354,9 @@ class SettingsRepo(
|
|||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
|
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR),
|
||||||
|
tiandituKey = preferences.getString(keyTiandituKey, null) ?: ""
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>) {
|
||||||
look4SatDao.deleteRadios()
|
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -45,7 +46,10 @@ class RemoteSource(
|
|||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
httpClient.newCall(networkRequest).execute().body.byteStream()
|
httpClient.newCall(networkRequest).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@withContext null
|
||||||
|
ByteArrayInputStream(response.body.bytes())
|
||||||
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource network stream exception: $exception")
|
||||||
null
|
null
|
||||||
|
|||||||
@@ -57,9 +57,24 @@ data class OtherSettings(
|
|||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto"
|
val sstvMode: String = "Auto",
|
||||||
|
val mapSource: String = MapSource.TIANDITU_VECTOR,
|
||||||
|
val tiandituKey: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object MapSource {
|
||||||
|
const val OSM = "osm"
|
||||||
|
const val TIANDITU = "tianditu"
|
||||||
|
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||||
|
const val TIANDITU_IMAGE = "tianditu_image"
|
||||||
|
|
||||||
|
fun normalize(source: String): String = when (source) {
|
||||||
|
TIANDITU -> TIANDITU_VECTOR
|
||||||
|
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||||
|
else -> TIANDITU_VECTOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val useCustomTLE: Boolean,
|
||||||
val useCustomTransceivers: Boolean,
|
val useCustomTransceivers: Boolean,
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val orientation: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.utility
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
import kotlin.math.acos
|
import kotlin.math.acos
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
@@ -85,6 +87,13 @@ fun clipLon(longitude: Double): Double {
|
|||||||
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun OrbitalPos.toMapGeoPos(): GeoPos {
|
||||||
|
return GeoPos(
|
||||||
|
latitude = clipLat(latitude.toDegrees()),
|
||||||
|
longitude = clipLon(longitude.toDegrees())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
|
||||||
return min(max(currentValue, minValue), maxValue)
|
return min(max(currentValue, minValue), maxValue)
|
||||||
}
|
}
|
||||||
+424
@@ -0,0 +1,424 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.LinearGradient
|
||||||
|
import android.graphics.RadialGradient
|
||||||
|
import android.graphics.Shader
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.core.graphics.withRotation
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class BubblesStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bubbleCount: Int = 16,
|
||||||
|
val minBubbleCount: Int = 8,
|
||||||
|
val maxBubbleCount: Int = 16,
|
||||||
|
val adaptiveBubbleCount: Boolean = true,
|
||||||
|
val spawnIntervalMs: Long = 800L,
|
||||||
|
val bubbleRadiusFraction: Float = 0.24f,
|
||||||
|
val adaptiveSizing: Boolean = true,
|
||||||
|
val referenceMinSizePx: Float = 360f,
|
||||||
|
val referenceAreaPx: Float = 360f * 800f,
|
||||||
|
val minBubbleRadiusPx: Float = 16f,
|
||||||
|
val maxBubbleRadiusPx: Float = 256f,
|
||||||
|
val speedScale: Float = 0.99f,
|
||||||
|
val minVelocity: Float = 0.8f,
|
||||||
|
val maxVelocity: Float = 3.2f,
|
||||||
|
val hueRotationSpeedDps: Float = 60f, // degrees per second
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BubblesEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: BubblesStyle = BubblesStyle(),
|
||||||
|
) {
|
||||||
|
val renderer = remember { BubblesRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
LaunchedEffect(isRunning, style) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 16_666_667L // ~60 FPS
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private val SHELL_GRADIENT_STOPS = floatArrayOf(0f, 0.52f, 0.66f, 0.79f, 0.90f, 0.968f, 0.993f, 1f)
|
||||||
|
private val INNER_GRADIENT_STOPS = floatArrayOf(0f, 0.34f, 0.68f, 1f)
|
||||||
|
|
||||||
|
private data class Velocity(var x: Float, var y: Float)
|
||||||
|
|
||||||
|
private data class Bubble(
|
||||||
|
var x: Float,
|
||||||
|
var y: Float,
|
||||||
|
val radius: Float,
|
||||||
|
val velocity: Velocity,
|
||||||
|
val baseHue: Float, // 0-360, unique for each bubble
|
||||||
|
)
|
||||||
|
|
||||||
|
private class BubblesRenderer {
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private val bubbles = mutableListOf<Bubble>()
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
private var globalHueRotation = 0f // degrees, rotates all bubbles hues
|
||||||
|
private var spawnAccumulatorSec = 0f
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val shellColors = IntArray(8)
|
||||||
|
private val innerColors = IntArray(4)
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size) {
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
val shouldRebuild = width != targetWidth || height != targetHeight
|
||||||
|
if (shouldRebuild) {
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
bubbles.clear()
|
||||||
|
spawnAccumulatorSec = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawnBubble(style: BubblesStyle): Boolean {
|
||||||
|
if (width <= 0f || height <= 0f) return false
|
||||||
|
val radius = resolveBubbleRadius(style)
|
||||||
|
val (velocityMin, velocityMax) = resolveVelocityRange(style)
|
||||||
|
val bubble = Bubble(
|
||||||
|
x = radius,
|
||||||
|
y = height - radius,
|
||||||
|
radius = radius,
|
||||||
|
velocity = Velocity(
|
||||||
|
x = randomInRange(velocityMin, velocityMax),
|
||||||
|
y = -randomInRange(velocityMin, velocityMax),
|
||||||
|
),
|
||||||
|
baseHue = random.nextFloat() * 360f, // Random starting hue for this bubble
|
||||||
|
)
|
||||||
|
bubbles.add(bubble)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
|
||||||
|
spawnMissingBubbles(deltaSeconds, style)
|
||||||
|
|
||||||
|
// Rotate hue for all bubbles
|
||||||
|
globalHueRotation += style.hueRotationSpeedDps * deltaSeconds
|
||||||
|
if (globalHueRotation >= 360f) globalHueRotation -= 360f
|
||||||
|
val frameScale = deltaSeconds * 60f
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
val bubble = bubbles[i]
|
||||||
|
// Update position
|
||||||
|
bubble.x += bubble.velocity.x * frameScale
|
||||||
|
bubble.y += bubble.velocity.y * frameScale
|
||||||
|
// Bounce off walls
|
||||||
|
if (bubble.x > width - bubble.radius) {
|
||||||
|
bubble.x = width - bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.x < bubble.radius) {
|
||||||
|
bubble.x = bubble.radius
|
||||||
|
bubble.velocity.x *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y > height - bubble.radius) {
|
||||||
|
bubble.y = height - bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
if (bubble.y < bubble.radius) {
|
||||||
|
bubble.y = bubble.radius
|
||||||
|
bubble.velocity.y *= -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collision detection and resolution
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
for (j in (i + 1) until bubbles.size) {
|
||||||
|
val b1 = bubbles[i]
|
||||||
|
val b2 = bubbles[j]
|
||||||
|
if (isCollided(b1, b2)) resolveCollision(b1, b2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun spawnMissingBubbles(deltaSeconds: Float, style: BubblesStyle) {
|
||||||
|
val targetCount = resolveTargetBubbleCount(style)
|
||||||
|
if (bubbles.size >= targetCount) return
|
||||||
|
|
||||||
|
val spawnIntervalSec = style.spawnIntervalMs.coerceAtLeast(1L) / 1_000f
|
||||||
|
spawnAccumulatorSec += deltaSeconds
|
||||||
|
|
||||||
|
while (bubbles.size < targetCount && spawnAccumulatorSec >= spawnIntervalSec) {
|
||||||
|
if (!spawnBubble(style)) break
|
||||||
|
spawnAccumulatorSec -= spawnIntervalSec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: DrawScope, style: BubblesStyle) {
|
||||||
|
if (width <= 0f || height <= 0f) return
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (i in bubbles.indices) {
|
||||||
|
drawBubbleWithGradient(canvas, bubbles[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawBubbleWithGradient(canvas: android.graphics.Canvas, bubble: Bubble) {
|
||||||
|
val hue = (bubble.baseHue + globalHueRotation) % 360f
|
||||||
|
val lit = 53.33f + maxOf(0f, (70f - kotlin.math.abs(244f - hue)) / 4f)
|
||||||
|
val rgb = hueToRgb(hue, lit)
|
||||||
|
val shellColor = mixWithWhite(rgb, 0.04f)
|
||||||
|
val innerColor = mixWithWhite(rgb, 0.10f)
|
||||||
|
val shimmerColor = mixWithWhite(rgb, 0.35f)
|
||||||
|
// Outer shell
|
||||||
|
val shellGradient = RadialGradient(
|
||||||
|
bubble.x, bubble.y, bubble.radius,
|
||||||
|
buildShellColors(shellColor),
|
||||||
|
SHELL_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = shellGradient
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius, paint)
|
||||||
|
// Subtle thin rim
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.STROKE
|
||||||
|
paint.strokeWidth = maxOf(1f, bubble.radius * 0.024f)
|
||||||
|
paint.color = colorWithAlpha(mixWithWhite(shellColor, 0.08f), 0.30f)
|
||||||
|
canvas.drawCircle(bubble.x, bubble.y, bubble.radius - paint.strokeWidth * 0.5f, paint)
|
||||||
|
// Top internal bubble
|
||||||
|
val innerTop = bubble.y - bubble.radius * 0.96f
|
||||||
|
val innerBottom = bubble.y + bubble.radius * 0.48f
|
||||||
|
val innerLeft = bubble.x - bubble.radius * 0.84f
|
||||||
|
val innerRight = bubble.x + bubble.radius * 0.84f
|
||||||
|
val innerGradient = LinearGradient(
|
||||||
|
bubble.x,
|
||||||
|
innerTop,
|
||||||
|
bubble.x,
|
||||||
|
innerBottom,
|
||||||
|
buildInnerColors(innerColor),
|
||||||
|
INNER_GRADIENT_STOPS,
|
||||||
|
Shader.TileMode.CLAMP
|
||||||
|
)
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.shader = innerGradient
|
||||||
|
canvas.drawOval(
|
||||||
|
innerLeft,
|
||||||
|
innerTop,
|
||||||
|
innerRight,
|
||||||
|
innerBottom,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
// Top-left shimmer
|
||||||
|
paint.shader = null
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
paint.color = colorWithAlpha(shimmerColor, 0.95f)
|
||||||
|
val shimmerCx = bubble.x - bubble.radius * 0.40f
|
||||||
|
val shimmerCy = bubble.y - bubble.radius * 0.72f
|
||||||
|
val shimmerHalfWidth = bubble.radius * 0.06f
|
||||||
|
val shimmerHalfHeight = bubble.radius * 0.21f
|
||||||
|
canvas.withRotation(60f, shimmerCx, shimmerCy) {
|
||||||
|
drawOval(
|
||||||
|
shimmerCx - shimmerHalfWidth,
|
||||||
|
shimmerCy - shimmerHalfHeight,
|
||||||
|
shimmerCx + shimmerHalfWidth,
|
||||||
|
shimmerCy + shimmerHalfHeight,
|
||||||
|
paint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCollided(bubble1: Bubble, bubble2: Bubble): Boolean {
|
||||||
|
val dx = bubble1.x - bubble2.x
|
||||||
|
val dy = bubble1.y - bubble2.y
|
||||||
|
val radius = (bubble1.radius + bubble2.radius) * 0.9f
|
||||||
|
return dx * dx + dy * dy < radius * radius
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCollision(particle: Bubble, otherParticle: Bubble) {
|
||||||
|
val xVelocityDiff = particle.velocity.x - otherParticle.velocity.x
|
||||||
|
val yVelocityDiff = particle.velocity.y - otherParticle.velocity.y
|
||||||
|
val xDist = otherParticle.x - particle.x
|
||||||
|
val yDist = otherParticle.y - particle.y
|
||||||
|
// Prevent accidental overlap
|
||||||
|
if (xVelocityDiff * xDist + yVelocityDiff * yDist >= 0) {
|
||||||
|
val angle = -atan2(otherParticle.y - particle.y, otherParticle.x - particle.x)
|
||||||
|
val m1 = 1f
|
||||||
|
val m2 = 1f
|
||||||
|
val u1 = rotate(particle.velocity, angle)
|
||||||
|
val u2 = rotate(otherParticle.velocity, angle)
|
||||||
|
val v1 = Velocity(
|
||||||
|
x = (u1.x * (m1 - m2)) / (m1 + m2) + (u2.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u1.y,
|
||||||
|
)
|
||||||
|
val v2 = Velocity(
|
||||||
|
x = (u2.x * (m1 - m2)) / (m1 + m2) + (u1.x * 2 * m2) / (m1 + m2),
|
||||||
|
y = u2.y,
|
||||||
|
)
|
||||||
|
val vFinal1 = rotate(v1, -angle)
|
||||||
|
val vFinal2 = rotate(v2, -angle)
|
||||||
|
particle.velocity.x = vFinal1.x
|
||||||
|
particle.velocity.y = vFinal1.y
|
||||||
|
otherParticle.velocity.x = vFinal2.x
|
||||||
|
otherParticle.velocity.y = vFinal2.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rotate(velocity: Velocity, angle: Float): Velocity {
|
||||||
|
return Velocity(
|
||||||
|
x = velocity.x * cos(angle) - velocity.y * sin(angle),
|
||||||
|
y = velocity.x * sin(angle) + velocity.y * cos(angle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildShellColors(shellColor: Int): IntArray {
|
||||||
|
shellColors[0] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[1] = colorWithAlpha(shellColor, 0f)
|
||||||
|
shellColors[2] = colorWithAlpha(shellColor, 0.04f)
|
||||||
|
shellColors[3] = colorWithAlpha(shellColor, 0.12f)
|
||||||
|
shellColors[4] = colorWithAlpha(shellColor, 0.28f)
|
||||||
|
shellColors[5] = colorWithAlpha(shellColor, 0.44f)
|
||||||
|
shellColors[6] = colorWithAlpha(shellColor, 0.58f)
|
||||||
|
shellColors[7] = colorWithAlpha(shellColor, 0.64f)
|
||||||
|
return shellColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildInnerColors(innerColor: Int): IntArray {
|
||||||
|
innerColors[0] = colorWithAlpha(innerColor, 0.48f)
|
||||||
|
innerColors[1] = colorWithAlpha(innerColor, 0.36f)
|
||||||
|
innerColors[2] = colorWithAlpha(innerColor, 0.08f)
|
||||||
|
innerColors[3] = colorWithAlpha(innerColor, 0f)
|
||||||
|
return innerColors
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBubbleRadius(style: BubblesStyle): Float {
|
||||||
|
val minDimension = min(width, height)
|
||||||
|
val baseRadius = minDimension * style.bubbleRadiusFraction
|
||||||
|
val minRadius = style.minBubbleRadiusPx.coerceAtLeast(1f)
|
||||||
|
val maxRadius = maxOf(minRadius, style.maxBubbleRadiusPx)
|
||||||
|
if (!style.adaptiveSizing || minDimension <= 0f) {
|
||||||
|
return baseRadius.coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
val reference = style.referenceMinSizePx.coerceAtLeast(1f)
|
||||||
|
val dampening = sqrt((reference / minDimension).coerceAtMost(1f))
|
||||||
|
return (baseRadius * dampening).coerceIn(minRadius, maxRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTargetBubbleCount(style: BubblesStyle): Int {
|
||||||
|
val baseCount = style.bubbleCount.coerceAtLeast(1)
|
||||||
|
if (!style.adaptiveBubbleCount) return baseCount
|
||||||
|
|
||||||
|
val area = width * height
|
||||||
|
val referenceArea = style.referenceAreaPx.coerceAtLeast(1f)
|
||||||
|
val areaScale = sqrt((area / referenceArea).coerceAtLeast(0.25f))
|
||||||
|
val scaledCount = (baseCount * areaScale).toInt()
|
||||||
|
val minCount = style.minBubbleCount.coerceAtLeast(1)
|
||||||
|
val maxCount = maxOf(minCount, style.maxBubbleCount.coerceAtLeast(1))
|
||||||
|
return scaledCount.coerceIn(minCount, maxCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveVelocityRange(style: BubblesStyle): Pair<Float, Float> {
|
||||||
|
val referenceMaxVelocity = maxOf(height / 300f, 1f)
|
||||||
|
val velocityMin = (style.minVelocity * style.speedScale).coerceAtLeast(0.05f)
|
||||||
|
val velocityMax = maxOf(velocityMin, min(style.maxVelocity, referenceMaxVelocity) * style.speedScale)
|
||||||
|
return velocityMin to velocityMax
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomInRange(min: Float, max: Float): Float {
|
||||||
|
return random.nextFloat() * (max - min) + min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hueToRgb(h: Float, l: Float): Int {
|
||||||
|
val hNorm = h / 360f
|
||||||
|
val lNorm = l / 100f
|
||||||
|
val sNorm = 1f
|
||||||
|
val c = (1f - kotlin.math.abs(2f * lNorm - 1f)) * sNorm
|
||||||
|
val hp = hNorm * 6f
|
||||||
|
val x = c * (1f - kotlin.math.abs((hp % 2f) - 1f))
|
||||||
|
val m = lNorm - c / 2f
|
||||||
|
val (r, g, b) = when {
|
||||||
|
hp < 1f -> Triple(c, x, 0f)
|
||||||
|
hp < 2f -> Triple(x, c, 0f)
|
||||||
|
hp < 3f -> Triple(0f, c, x)
|
||||||
|
hp < 4f -> Triple(0f, x, c)
|
||||||
|
hp < 5f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
val r8 = ((r + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val g8 = ((g + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
val b8 = ((b + m) * 255).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (r8 shl 16) or (g8 shl 8) or b8
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun colorWithAlpha(color: Int, alpha: Float): Int {
|
||||||
|
val a = (alpha * 255).toInt().coerceIn(0, 255)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mixWithWhite(color: Int, amount: Float): Int {
|
||||||
|
val t = amount.coerceIn(0f, 1f)
|
||||||
|
val r = (color shr 16) and 0xFF
|
||||||
|
val g = (color shr 8) and 0xFF
|
||||||
|
val b = color and 0xFF
|
||||||
|
val mixedR = (r + (255 - r) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedG = (g + (255 - g) * t).toInt().coerceIn(0, 255)
|
||||||
|
val mixedB = (b + (255 - b) * t).toInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (mixedR shl 16) or (mixedG shl 8) or mixedB
|
||||||
|
}
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* 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.presentation
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MatrixStyle(
|
||||||
|
val bgColor: Color = Color.Black,
|
||||||
|
val bodyColor: Color = Color(0xFF29C94A),
|
||||||
|
val headColor: Color = Color(0xFFC2FFC6),
|
||||||
|
val tailAlphaFloor: Float = 0.14f, // 0.14f - 0.18f,
|
||||||
|
val fontSize: TextUnit = 14.sp, // 11.sp - 14.sp
|
||||||
|
val minStreamLength: Int = 8, // 6 - 8
|
||||||
|
val maxStreamLength: Int = 28, // 20 - 28
|
||||||
|
val minSpeedRps: Float = 10f, // 10f - 15f
|
||||||
|
val maxSpeedRps: Float = 38f, // 38f - 45f
|
||||||
|
val resetPauseSec: ClosedFloatingPointRange<Float> = 0.1f..1.0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MatrixEffect(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isRunning: Boolean = true,
|
||||||
|
style: MatrixStyle = MatrixStyle(),
|
||||||
|
symbols: String = DEFAULT_SYMBOLS,
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val renderer = remember { MatrixRenderer() }
|
||||||
|
var frameSignal by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
LaunchedEffect(isRunning, style, symbols) {
|
||||||
|
if (!isRunning) return@LaunchedEffect
|
||||||
|
|
||||||
|
var previousNanos = 0L
|
||||||
|
while (isActive) {
|
||||||
|
withFrameNanos { now ->
|
||||||
|
if (previousNanos == 0L) previousNanos = now
|
||||||
|
val deltaSec = ((now - previousNanos).coerceAtMost(MAX_STEP_NANOS)).toFloat() / NANOS_TO_SECONDS
|
||||||
|
previousNanos = now
|
||||||
|
renderer.update(deltaSec, style)
|
||||||
|
frameSignal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
frameSignal
|
||||||
|
renderer.ensureLayout(size, density.density, style, symbols)
|
||||||
|
renderer.draw(this, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_STEP_NANOS = 33_333_333L
|
||||||
|
private const val NANOS_TO_SECONDS = 1_000_000_000f
|
||||||
|
private const val DEFAULT_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789<>=*+-~:;/[]{}()"
|
||||||
|
|
||||||
|
private fun Color.toArgb(): Int {
|
||||||
|
val a = (alpha.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val r = (red.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val g = (green.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
val b = (blue.coerceIn(0f, 1f) * 255f + 0.5f).toInt()
|
||||||
|
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MatrixRenderer {
|
||||||
|
private var columns = 0
|
||||||
|
private var rows = 0
|
||||||
|
private var width = 0f
|
||||||
|
private var height = 0f
|
||||||
|
private var fontSizePx = 0f
|
||||||
|
private var charWidth = 0f
|
||||||
|
private var charHeight = 0f
|
||||||
|
private var baselineOffset = 0f
|
||||||
|
private var symbolSet = ""
|
||||||
|
private var symbols = charArrayOf()
|
||||||
|
|
||||||
|
private var glyphs = charArrayOf()
|
||||||
|
private var streams = emptyArray<StreamState>()
|
||||||
|
|
||||||
|
private val bodyPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE }
|
||||||
|
private val charBuffer = CharArray(1)
|
||||||
|
private val random = Random(System.currentTimeMillis())
|
||||||
|
|
||||||
|
fun ensureLayout(size: Size, density: Float, style: MatrixStyle, symbols: String) {
|
||||||
|
val targetFontPx = style.fontSize.value * density
|
||||||
|
val targetWidth = size.width
|
||||||
|
val targetHeight = size.height
|
||||||
|
val targetSymbols = symbols.ifBlank { DEFAULT_SYMBOLS }
|
||||||
|
|
||||||
|
if (targetWidth <= 0f || targetHeight <= 0f) return
|
||||||
|
|
||||||
|
val shouldRebuild = width != targetWidth ||
|
||||||
|
height != targetHeight ||
|
||||||
|
fontSizePx != targetFontPx ||
|
||||||
|
symbolSet != targetSymbols
|
||||||
|
|
||||||
|
if (!shouldRebuild) return
|
||||||
|
|
||||||
|
width = targetWidth
|
||||||
|
height = targetHeight
|
||||||
|
fontSizePx = targetFontPx
|
||||||
|
symbolSet = targetSymbols
|
||||||
|
this.symbols = targetSymbols.toCharArray()
|
||||||
|
|
||||||
|
bodyPaint.textSize = targetFontPx
|
||||||
|
headPaint.textSize = targetFontPx
|
||||||
|
charWidth = max(bodyPaint.measureText("W"), 1f)
|
||||||
|
val metrics = bodyPaint.fontMetrics
|
||||||
|
charHeight = max(metrics.descent - metrics.ascent, 1f)
|
||||||
|
baselineOffset = -metrics.ascent
|
||||||
|
|
||||||
|
columns = max((width / charWidth).toInt(), 1)
|
||||||
|
rows = max((height / charHeight).toInt() + 2, 1)
|
||||||
|
|
||||||
|
glyphs = CharArray(columns * rows) { randomGlyph() }
|
||||||
|
streams = Array(columns) { StreamState.random(rows, style, random) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(deltaSeconds: Float, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0 || deltaSeconds <= 0f) return
|
||||||
|
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
stream.pauseSec -= deltaSeconds
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val previousHead = floor(stream.headRow).toInt()
|
||||||
|
stream.headRow += stream.speedRps * deltaSeconds
|
||||||
|
val newHead = floor(stream.headRow).toInt()
|
||||||
|
|
||||||
|
if (newHead > previousHead) {
|
||||||
|
for (row in (previousHead + 1)..newHead) {
|
||||||
|
if (row in 0 until rows) {
|
||||||
|
glyphs[row * columns + column] = randomGlyph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHead - stream.length > rows) {
|
||||||
|
stream.reset(rows, style, random)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(scope: androidx.compose.ui.graphics.drawscope.DrawScope, style: MatrixStyle) {
|
||||||
|
if (columns == 0 || rows == 0) return
|
||||||
|
|
||||||
|
scope.drawRect(style.bgColor)
|
||||||
|
|
||||||
|
bodyPaint.color = style.bodyColor.toArgb()
|
||||||
|
headPaint.color = style.headColor.toArgb()
|
||||||
|
val tailAlphaFloor = style.tailAlphaFloor.coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val canvas = scope.drawContext.canvas.nativeCanvas
|
||||||
|
for (column in streams.indices) {
|
||||||
|
val stream = streams[column]
|
||||||
|
if (stream.pauseSec > 0f) continue
|
||||||
|
|
||||||
|
val head = floor(stream.headRow).toInt()
|
||||||
|
val startRow = max(0, head - stream.length + 1)
|
||||||
|
val endRow = min(rows - 1, head)
|
||||||
|
if (startRow > endRow) continue
|
||||||
|
|
||||||
|
for (row in endRow downTo startRow) {
|
||||||
|
val tailIndex = head - row
|
||||||
|
|
||||||
|
val paint = if (tailIndex == 0) headPaint else bodyPaint
|
||||||
|
if (tailIndex != 0) {
|
||||||
|
val normalized = ((stream.length - tailIndex).toFloat() / stream.length).coerceIn(0f, 1f)
|
||||||
|
val alpha = tailAlphaFloor + (1f - tailAlphaFloor) * normalized
|
||||||
|
paint.alpha = (alpha * 255).toInt()
|
||||||
|
} else {
|
||||||
|
paint.alpha = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
val glyph = glyphs[row * columns + column]
|
||||||
|
charBuffer[0] = glyph
|
||||||
|
val x = column * charWidth
|
||||||
|
val y = row * charHeight + baselineOffset
|
||||||
|
canvas.drawText(charBuffer, 0, 1, x, y, paint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomGlyph(): Char = symbols[random.nextInt(symbols.size)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StreamState(
|
||||||
|
var headRow: Float,
|
||||||
|
var length: Int,
|
||||||
|
var speedRps: Float,
|
||||||
|
var pauseSec: Float,
|
||||||
|
) {
|
||||||
|
fun reset(rows: Int, style: MatrixStyle, random: Random) {
|
||||||
|
val randomOffset = random.nextFloat() * rows
|
||||||
|
headRow = -randomOffset
|
||||||
|
length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun random(rows: Int, style: MatrixStyle, random: Random): StreamState {
|
||||||
|
val length = random.nextInt(
|
||||||
|
from = style.minStreamLength.coerceAtLeast(2),
|
||||||
|
until = (style.maxStreamLength.coerceAtLeast(style.minStreamLength + 1) + 1),
|
||||||
|
)
|
||||||
|
return StreamState(
|
||||||
|
headRow = -random.nextFloat() * rows,
|
||||||
|
length = length,
|
||||||
|
speedRps = random.nextFloat() * (style.maxSpeedRps - style.minSpeedRps) + style.minSpeedRps,
|
||||||
|
pauseSec = random.nextFloat() * (style.resetPauseSec.endInclusive - style.resetPauseSec.start) +
|
||||||
|
style.resetPauseSec.start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
||||||
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
||||||
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
|
<string name="pass_welcome_title">Bienvenido a Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
||||||
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
<string name="prefs_bt_frequency_device_hint">Id dispositivo</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_frequency_output_hint">Formato de datos</string>
|
||||||
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</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_title">Otros ajustes</string>
|
||||||
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
||||||
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
||||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
|
<string name="pass_welcome_title">Добро пожаловать в Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
||||||
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
||||||
@@ -131,6 +131,7 @@
|
|||||||
<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_frequency_output_hint">Формат</string>
|
||||||
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</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_title">Другие настройки</string>
|
||||||
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
||||||
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
||||||
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
<string name="pass_welcome_title">Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
||||||
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
<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_frequency_output_hint">දත්ත අකාරය</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්ෂා ක°</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_title">වෙනත් සැකසුම්</string>
|
||||||
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat</string>
|
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||||
<string name="btn_accept">Kabul et</string>
|
<string name="btn_accept">Kabul et</string>
|
||||||
<string name="btn_cancel">İptal</string>
|
<string name="btn_cancel">İptal</string>
|
||||||
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||||
@@ -42,11 +42,11 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
||||||
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
||||||
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
|
<string name="pass_welcome_title">Look4Sat-CNMap\'a hoş geldiniz\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
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>
|
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||||
@@ -84,7 +84,8 @@
|
|||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Önceki</string>
|
<string name="map_prev">Önceki</string>
|
||||||
<string name="map_next">Sonraki</string>
|
<string name="map_next">Sonraki</string>
|
||||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||||
|
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||||
@@ -100,12 +101,12 @@
|
|||||||
<string name="map_visible">Görünür</string>
|
<string name="map_visible">Görünür</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
||||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||||
<string name="prefs_donate_title">Bağış yap</string>
|
<string name="prefs_donate_title">Bağış yap</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</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_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</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_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -175,6 +176,7 @@
|
|||||||
<string name="prefs_bt_frequency_device_hint">Cihaz kimliği</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">Veri biçimi</string>
|
||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
||||||
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
||||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
|
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
||||||
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
||||||
@@ -132,6 +132,7 @@
|
|||||||
<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_frequency_output_hint">Формат даних</string>
|
||||||
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</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_title">Інші налаштування</string>
|
||||||
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<string name="pass_altitude">高度:%d km</string>
|
<string name="pass_altitude">高度:%d km</string>
|
||||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
<string name="pass_welcome_title">欢迎来到 Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -124,6 +124,7 @@
|
|||||||
<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_frequency_output_hint">数据格式</string>
|
||||||
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
|
||||||
|
<string name="prefs_net_perm_error">请检查网络权限</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他设置</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Commmon -->
|
<!-- Commmon -->
|
||||||
<string name="app_name" translatable="false">Look4Sat</string>
|
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||||
<string name="btn_accept">Accept</string>
|
<string name="btn_accept">Accept</string>
|
||||||
<string name="btn_cancel">Cancel</string>
|
<string name="btn_cancel">Cancel</string>
|
||||||
<string name="empty_list_message">No data to show</string>
|
<string name="empty_list_message">No data to show</string>
|
||||||
@@ -43,15 +43,14 @@
|
|||||||
<string name="pass_empty_list_message">
|
<string name="pass_empty_list_message">
|
||||||
Make sure your filters are correct and you have selected satellites</string>
|
Make sure your filters are correct and you have selected satellites</string>
|
||||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
||||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
<string name="pass_welcome_title">Welcome to Look4Sat-CNMap\!</string>
|
||||||
<string name="pass_welcome_message">
|
<string name="pass_welcome_message">
|
||||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
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>
|
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
\n\n* Added selected transceiver\'s frequency display to SSTV page
|
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
\n\n* Fixed Reset/Save/Play buttons behavior and states on SSTV page
|
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -83,7 +82,8 @@
|
|||||||
<!-- Map screen -->
|
<!-- Map screen -->
|
||||||
<string name="map_prev">Prev</string>
|
<string name="map_prev">Prev</string>
|
||||||
<string name="map_next">Next</string>
|
<string name="map_next">Next</string>
|
||||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||||
|
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
<string name="map_elevation">Elevation: %.1f°</string>
|
<string name="map_elevation">Elevation: %.1f°</string>
|
||||||
<string name="map_altitude">Altitude: %.0f km</string>
|
<string name="map_altitude">Altitude: %.0f km</string>
|
||||||
@@ -99,12 +99,12 @@
|
|||||||
<string name="map_visible">Visible</string>
|
<string name="map_visible">Visible</string>
|
||||||
|
|
||||||
<!-- Settings screen -->
|
<!-- Settings screen -->
|
||||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
||||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||||
<string name="prefs_donate_title">Donate</string>
|
<string name="prefs_donate_title">Donate</string>
|
||||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</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_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||||
<string name="prefs_github_title" translatable="false">GitHub</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_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||||
@@ -148,6 +148,13 @@
|
|||||||
<string name="prefs_bt_output">Bluetooth</string>
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
<string name="prefs_cat_output">CAT</string>
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
|
||||||
|
<string name="prefs_map_title">Map settings</string>
|
||||||
|
<string name="prefs_map_source_osm" translatable="false">OSM</string>
|
||||||
|
<string name="prefs_map_source_tianditu_vector" translatable="false">天地图-矢量</string>
|
||||||
|
<string name="prefs_map_source_tianditu_image" translatable="false">天地图-影像</string>
|
||||||
|
<string name="prefs_map_tianditu_key" translatable="false">天地图密钥</string>
|
||||||
|
<string name="prefs_map_save_key">保存密钥</string>
|
||||||
|
|
||||||
<!-- Radio Control -->
|
<!-- Radio Control -->
|
||||||
<string name="nav_radiocontrol">Radio Control</string>
|
<string name="nav_radiocontrol">Radio Control</string>
|
||||||
<string name="rc_settings_title">CAT Radio Control</string>
|
<string name="rc_settings_title">CAT Radio Control</string>
|
||||||
@@ -174,6 +181,7 @@
|
|||||||
<string name="prefs_bt_frequency_device_hint">Device id</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_output_hint">Data format</string>
|
||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||||
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
<string name="prefs_other_title">Other settings</string>
|
<string name="prefs_other_title">Other settings</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
* Enabled continuous SSTV decoding for manually selected types (Play button enables/disables decoding)
|
* Added required tweaks to support Android 17 (API 37)
|
||||||
* Added selected transceiver\'s frequency display to SSTV page
|
* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||||
* Fixed Reset/Save/Play buttons behavior and states on SSTV page
|
|
||||||
@@ -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.feature.map
|
|
||||||
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.RectF
|
|
||||||
import org.osmdroid.views.MapView
|
|
||||||
import org.osmdroid.views.overlay.Overlay
|
|
||||||
import kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom osmdroid overlay that shades the night side of the globe.
|
|
||||||
*
|
|
||||||
* Works entirely in screen-pixel space: for each vertical strip on screen it
|
|
||||||
* asks osmdroid for the geographic coordinate, then tests whether that point is
|
|
||||||
* in the night half-sphere relative to the sub-solar point. Because the
|
|
||||||
* computation happens during draw() the result is always correct regardless
|
|
||||||
* of zoom level or map scroll position — no polygon winding issues possible.
|
|
||||||
*
|
|
||||||
* A point (latRad, lonRad) is in night when the angle to the sub-solar point
|
|
||||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
|
||||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
|
||||||
*
|
|
||||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
|
||||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
|
||||||
* evaluations per row, which is imperceptible.
|
|
||||||
*/
|
|
||||||
class MapNightOverlay : Overlay() {
|
|
||||||
|
|
||||||
/** Sub-solar latitude in degrees. */
|
|
||||||
var sunLatDeg: Double = 0.0
|
|
||||||
|
|
||||||
/** Sub-solar longitude in degrees. */
|
|
||||||
var sunLonDeg: Double = 0.0
|
|
||||||
|
|
||||||
private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
color = Color.argb(75, 0, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val rect = RectF()
|
|
||||||
|
|
||||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
|
||||||
if (shadow) return
|
|
||||||
|
|
||||||
val proj = mapView.projection
|
|
||||||
val sunLatRad = Math.toRadians(sunLatDeg)
|
|
||||||
val sunLonRad = Math.toRadians(sunLonDeg)
|
|
||||||
val sinSunLat = sin(sunLatRad)
|
|
||||||
val cosSunLat = cos(sunLatRad)
|
|
||||||
|
|
||||||
val w = mapView.width
|
|
||||||
val h = mapView.height
|
|
||||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
|
||||||
|
|
||||||
// We scan column by column. For each column we determine the longitude,
|
|
||||||
// then find the latitude range that is in night and shade it.
|
|
||||||
// Since longitude is constant along a vertical strip and the day/night
|
|
||||||
// boundary at a given longitude is at most two latitudes, we can do a
|
|
||||||
// scan-line fill efficiently.
|
|
||||||
|
|
||||||
var x = 0
|
|
||||||
while (x < w) {
|
|
||||||
// Get the geographic coordinate at the top and bottom of this column.
|
|
||||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
|
||||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
|
||||||
|
|
||||||
val lonRad = Math.toRadians(geoTop.longitude)
|
|
||||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
|
||||||
|
|
||||||
// Top pixel geographic lat
|
|
||||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
|
||||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
|
||||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
|
||||||
|
|
||||||
// dot(sunVec, pointVec) < 0 → night
|
|
||||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
|
||||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
|
||||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
|
||||||
|
|
||||||
when {
|
|
||||||
dotTop < 0 && dotBot < 0 -> {
|
|
||||||
// Entire column is night — shade from top to bottom
|
|
||||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
}
|
|
||||||
|
|
||||||
dotTop >= 0 && dotBot >= 0 -> {
|
|
||||||
// Entire column is day — nothing to draw
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Terminator crosses this column — find the crossing pixel by binary search
|
|
||||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
|
||||||
if (dotTop < 0) {
|
|
||||||
// Night at top, day at bottom
|
|
||||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
} else {
|
|
||||||
// Day at top, night at bottom
|
|
||||||
rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
|
|
||||||
canvas.drawRect(rect, nightPaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
x += stepPx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
|
||||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
|
||||||
*/
|
|
||||||
private fun findCrossingY(
|
|
||||||
proj: org.osmdroid.views.Projection,
|
|
||||||
x: Int,
|
|
||||||
yTop: Int,
|
|
||||||
yBot: Int,
|
|
||||||
sinSunLat: Double,
|
|
||||||
cosSunLat: Double,
|
|
||||||
cosLonDiff: Double
|
|
||||||
): Int {
|
|
||||||
var lo = yTop
|
|
||||||
var hi = yBot
|
|
||||||
while (hi - lo > 1) {
|
|
||||||
val mid = (lo + hi) / 2
|
|
||||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
|
||||||
val latRad = Math.toRadians(geo.latitude)
|
|
||||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
|
||||||
if (dot < 0) hi = mid else lo = mid
|
|
||||||
}
|
|
||||||
return (lo + hi) / 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
@@ -71,37 +72,56 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
|||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||||
|
import org.osmdroid.config.Configuration
|
||||||
|
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||||
|
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.util.MapTileIndex
|
||||||
import org.osmdroid.views.CustomZoomButtonsController
|
import org.osmdroid.views.CustomZoomButtonsController
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.FolderOverlay
|
import org.osmdroid.views.overlay.FolderOverlay
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
import org.osmdroid.views.overlay.Polyline
|
import org.osmdroid.views.overlay.Polyline
|
||||||
|
import org.osmdroid.views.overlay.TilesOverlay
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
// Overlay indices
|
// Overlay indices
|
||||||
private const val OVERLAY_STATION = 0
|
private const val OVERLAY_TDT_LABELS = 0
|
||||||
private const val OVERLAY_TRACK = 1
|
private const val OVERLAY_STATION = 1
|
||||||
private const val OVERLAY_FOOTPRINT = 2
|
private const val OVERLAY_TRACK = 2
|
||||||
private const val OVERLAY_POSITIONS = 3
|
private const val OVERLAY_FOOTPRINT = 3
|
||||||
private const val OVERLAY_TERMINATOR = 4
|
private const val OVERLAY_POSITIONS = 4
|
||||||
private const val OVERLAY_SUN = 5
|
private const val OVERLAY_SUN = 5
|
||||||
private const val OVERLAY_MOON = 6
|
private const val OVERLAY_MOON = 6
|
||||||
private const val OVERLAY_COUNT = 7
|
private const val OVERLAY_COUNT = 7
|
||||||
|
|
||||||
private val minLat = MapView.getTileSystem().minLatitude
|
private val minLat = MapView.getTileSystem().minLatitude
|
||||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||||
|
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||||
|
private const val OFFLINE_MAX_ZOOM = 7.0
|
||||||
|
private const val OSM_MAX_ZOOM = 19.0
|
||||||
|
private const val TIANDITU_MAX_ZOOM = 18.0
|
||||||
|
private const val TILE_CACHE_MAX_BYTES = 1024L * 1024L * 1024L
|
||||||
|
private const val TILE_CACHE_TRIM_BYTES = 900L * 1024L * 1024L
|
||||||
|
private const val TILE_REFRESH_DELAY_MS = 250L
|
||||||
|
private const val TILE_SOURCE_OSM = "osm"
|
||||||
|
private const val TILE_USER_AGENT = "Look4Sat-CNMap"
|
||||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
strokeWidth = 3f
|
strokeWidth = 4f
|
||||||
style = Paint.Style.STROKE
|
style = Paint.Style.STROKE
|
||||||
color = Color.RED
|
color = "#1565C0".toColorInt()
|
||||||
strokeCap = Paint.Cap.ROUND
|
strokeCap = Paint.Cap.ROUND
|
||||||
strokeJoin = Paint.Join.ROUND
|
strokeJoin = Paint.Join.ROUND
|
||||||
}
|
}
|
||||||
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
strokeWidth = 3f
|
strokeWidth = 4f
|
||||||
style = Paint.Style.FILL_AND_STROKE
|
style = Paint.Style.STROKE
|
||||||
color = "#FFE082".toColorInt()
|
color = "#00897B".toColorInt()
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
strokeJoin = Paint.Join.ROUND
|
||||||
}
|
}
|
||||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
textSize = 36f
|
textSize = 36f
|
||||||
@@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
val rotateMod = Modifier.rotate(180f)
|
val rotateMod = Modifier.rotate(180f)
|
||||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||||
|
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
|
||||||
|
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu
|
||||||
|
else R.string.map_copyright_osm
|
||||||
|
|
||||||
LaunchedEffect(uiState.track) {
|
LaunchedEffect(uiState.track) {
|
||||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
|
||||||
|
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
|
||||||
|
}
|
||||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
@@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||||
uiState.footprint?.let { setFootprint(it, view) }
|
uiState.footprint?.let { setFootprint(it, view) }
|
||||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
|
||||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||||
view.invalidate()
|
view.invalidate()
|
||||||
}
|
}
|
||||||
uiState.mapData?.let { mapData ->
|
uiState.mapData?.let { mapData ->
|
||||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
if (isVertical) MapDataCard(mapData, copyrightResId) else MapDataCards(mapData, copyrightResId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,11 +203,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
|||||||
|
|
||||||
// region Map data composables
|
// region Map data composables
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCard(data: MapData) {
|
private fun MapDataCard(data: MapData, copyrightResId: Int) {
|
||||||
val textColor = MaterialTheme.colorScheme.primary
|
val textColor = MaterialTheme.colorScheme.primary
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
|
||||||
Card(colors = cardColors) {
|
Card(colors = cardColors) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
MapDataRow(
|
MapDataRow(
|
||||||
@@ -220,7 +245,7 @@ private fun MapDataRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MapDataCards(data: MapData) {
|
private fun MapDataCards(data: MapData, copyrightResId: Int) {
|
||||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||||
val paddingMod = Modifier
|
val paddingMod = Modifier
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
@@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.map_copyright),
|
text = stringResource(copyrightResId),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter)
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
)
|
)
|
||||||
@@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) {
|
|||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region Tile sources
|
||||||
|
private var configuredTileMapView: MapView? = null
|
||||||
|
private var configuredTileSource: String? = null
|
||||||
|
|
||||||
|
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
|
||||||
|
val key = tiandituKey.trim()
|
||||||
|
val layers = tiandituLayers(mapSource)
|
||||||
|
val tileSourceKey = if (layers == null || key.isBlank()) {
|
||||||
|
TILE_SOURCE_OSM
|
||||||
|
} else {
|
||||||
|
"tianditu:${layers.base}:${layers.label}:$key"
|
||||||
|
}
|
||||||
|
if (configuredTileMapView === mapView && configuredTileSource == tileSourceKey) {
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configuredTileMapView = mapView
|
||||||
|
configuredTileSource = tileSourceKey
|
||||||
|
|
||||||
|
if (layers == null || key.isBlank()) {
|
||||||
|
mapView.setUseDataConnection(true)
|
||||||
|
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TileSourceFactory.MAPNIK))
|
||||||
|
mapView.maxZoomLevel = OSM_MAX_ZOOM
|
||||||
|
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||||
|
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mapView.setUseDataConnection(true)
|
||||||
|
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.base, key = key)))
|
||||||
|
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
|
||||||
|
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||||
|
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
|
||||||
|
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
|
||||||
|
mapView.context
|
||||||
|
).apply {
|
||||||
|
applyTileOverlayDefaults()
|
||||||
|
setUseDataConnection(true)
|
||||||
|
}
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TiandituLayers(val base: String, val label: String)
|
||||||
|
|
||||||
|
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
|
||||||
|
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
|
||||||
|
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TilesOverlay.applyTileOverlayDefaults() {
|
||||||
|
setColorFilter(null)
|
||||||
|
setLoadingBackgroundColor(Color.TRANSPARENT)
|
||||||
|
setLoadingLineColor(Color.TRANSPARENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestTileRefresh(mapView: MapView) {
|
||||||
|
mapView.post {
|
||||||
|
mapView.requestLayout()
|
||||||
|
mapView.invalidate()
|
||||||
|
mapView.postInvalidate()
|
||||||
|
}
|
||||||
|
mapView.postDelayed({
|
||||||
|
mapView.invalidate()
|
||||||
|
mapView.postInvalidate()
|
||||||
|
}, TILE_REFRESH_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTiandituWmtsUrl(
|
||||||
|
baseUrl: String,
|
||||||
|
layer: String,
|
||||||
|
zoom: Int,
|
||||||
|
row: Int,
|
||||||
|
col: Int,
|
||||||
|
encodedKey: String
|
||||||
|
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
|
||||||
|
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
|
||||||
|
|
||||||
|
private class TiandituTileSource(
|
||||||
|
private val layer: String,
|
||||||
|
private val key: String
|
||||||
|
) : OnlineTileSourceBase(
|
||||||
|
"tianditu-wmts-https-$layer",
|
||||||
|
1,
|
||||||
|
18,
|
||||||
|
256,
|
||||||
|
".png",
|
||||||
|
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
|
||||||
|
"Tianditu"
|
||||||
|
) {
|
||||||
|
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
|
||||||
|
|
||||||
|
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||||
|
val zoom = MapTileIndex.getZoom(pMapTileIndex)
|
||||||
|
val row = MapTileIndex.getY(pMapTileIndex)
|
||||||
|
val col = MapTileIndex.getX(pMapTileIndex)
|
||||||
|
return buildTiandituWmtsUrl(
|
||||||
|
baseUrl = getBaseUrl(),
|
||||||
|
layer = layer,
|
||||||
|
zoom = zoom,
|
||||||
|
row = row,
|
||||||
|
col = col,
|
||||||
|
encodedKey = encodedKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
|
|
||||||
// region Map overlay helpers
|
// region Map overlay helpers
|
||||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
@@ -282,6 +415,8 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
|||||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||||
private val markerPool = HashMap<String, Marker>()
|
private val markerPool = HashMap<String, Marker>()
|
||||||
private var lastMapView: MapView? = null
|
private var lastMapView: MapView? = null
|
||||||
|
private var lastSatelliteTrack: List<List<GeoPos>>? = null
|
||||||
|
private var satelliteTrackOverlay: FolderOverlay? = null
|
||||||
|
|
||||||
private fun setPositions(
|
private fun setPositions(
|
||||||
posMap: Map<OrbitalObject, GeoPos>,
|
posMap: Map<OrbitalObject, GeoPos>,
|
||||||
@@ -294,8 +429,9 @@ private fun setPositions(
|
|||||||
lastMapView = mapView
|
lastMapView = mapView
|
||||||
markerPool.clear()
|
markerPool.clear()
|
||||||
iconCache.evictAll()
|
iconCache.evictAll()
|
||||||
footprintPolyline = null
|
lastSatelliteTrack = null
|
||||||
footprintPoints = null
|
satelliteTrackOverlay = null
|
||||||
|
footprintOverlay = null
|
||||||
}
|
}
|
||||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||||
@@ -357,6 +493,10 @@ private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||||
|
val cachedOverlay = satelliteTrackOverlay
|
||||||
|
if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val trackOverlay = FolderOverlay()
|
val trackOverlay = FolderOverlay()
|
||||||
try {
|
try {
|
||||||
satTrack.forEach { track ->
|
satTrack.forEach { track ->
|
||||||
@@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
|||||||
trackOverlay.add(this)
|
trackOverlay.add(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastSatelliteTrack = satTrack
|
||||||
|
satelliteTrackOverlay = trackOverlay
|
||||||
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reusable footprint Polyline — created once, points updated in-place each frame */
|
/** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */
|
||||||
private var footprintPolyline: Polyline? = null
|
private var footprintOverlay: FolderOverlay? = null
|
||||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
|
||||||
|
|
||||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||||
try {
|
try {
|
||||||
val rangeCircle = orbitalPos.getRangeCircle()
|
val rangeCircle = orbitalPos.getRangeCircle()
|
||||||
var pts = footprintPoints
|
val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it }
|
||||||
if (pts == null || pts.size != rangeCircle.size) {
|
overlay.items.clear()
|
||||||
pts = ArrayList(rangeCircle.size)
|
splitClosedPathOnDateLine(rangeCircle).forEach { segment ->
|
||||||
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
if (segment.size > 1) {
|
||||||
footprintPoints = pts
|
overlay.add(
|
||||||
} else {
|
Polyline().apply {
|
||||||
for (i in rangeCircle.indices) {
|
setPoints(segment.map { GeoPoint(it.latitude, it.longitude) })
|
||||||
pts[i].latitude = rangeCircle[i].latitude
|
outlinePaint.set(footprintOutlinePaint)
|
||||||
pts[i].longitude = rangeCircle[i].longitude
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val polyline = footprintPolyline ?: Polyline().apply {
|
mapView.overlays[OVERLAY_FOOTPRINT] = overlay
|
||||||
outlinePaint.set(footprintPaint)
|
|
||||||
footprintPolyline = this
|
|
||||||
}
|
|
||||||
polyline.setPoints(pts)
|
|
||||||
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println(e)
|
println(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun splitClosedPathOnDateLine(points: List<GeoPos>): List<List<GeoPos>> {
|
||||||
* Update the NightOverlay with the current sub-solar position.
|
if (points.isEmpty()) return emptyList()
|
||||||
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its
|
val segments = mutableListOf<List<GeoPos>>()
|
||||||
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
|
val segment = mutableListOf<GeoPos>()
|
||||||
*/
|
var previous = points.first()
|
||||||
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
segment.add(previous)
|
||||||
try {
|
(points.drop(1) + points.first()).forEach { current ->
|
||||||
val overlay = mapView.overlays[OVERLAY_TERMINATOR]
|
when {
|
||||||
if (overlay is MapNightOverlay) {
|
previous.longitude < -170.0 && current.longitude > 170.0 -> {
|
||||||
overlay.sunLatDeg = sunLatDeg
|
val edgeLatitude = getDateLineLatitude(previous, current, -180.0)
|
||||||
overlay.sunLonDeg = sunLonDeg
|
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||||
} else {
|
segments.add(segment.toList())
|
||||||
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
|
segment.clear()
|
||||||
this.sunLatDeg = sunLatDeg
|
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||||
this.sunLonDeg = sunLonDeg
|
}
|
||||||
|
previous.longitude > 170.0 && current.longitude < -170.0 -> {
|
||||||
|
val edgeLatitude = getDateLineLatitude(previous, current, 180.0)
|
||||||
|
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||||
|
segments.add(segment.toList())
|
||||||
|
segment.clear()
|
||||||
|
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
segment.add(current)
|
||||||
println(e)
|
previous = current
|
||||||
}
|
}
|
||||||
|
if (segment.size > 1) segments.add(segment)
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos, edgeLongitude: Double): Double {
|
||||||
|
val currentLongitude = when {
|
||||||
|
previous.longitude < -170.0 && current.longitude > 170.0 -> current.longitude - 360.0
|
||||||
|
previous.longitude > 170.0 && current.longitude < -170.0 -> current.longitude + 360.0
|
||||||
|
else -> current.longitude
|
||||||
|
}
|
||||||
|
val fraction = (edgeLongitude - previous.longitude) / (currentLongitude - previous.longitude)
|
||||||
|
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Place an ic_sun icon marker at the sub-solar point. */
|
/** Place an ic_sun icon marker at the sub-solar point. */
|
||||||
@@ -482,40 +637,62 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
|
|||||||
// region MapView lifecycle
|
// region MapView lifecycle
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewWithLifecycle(): MapView {
|
private fun rememberMapViewWithLifecycle(): MapView {
|
||||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isVertical = isVerticalLayout()
|
val isVertical = isVerticalLayout()
|
||||||
val mapView = remember {
|
val mapView = remember {
|
||||||
|
configureOsmdroidCache(context)
|
||||||
MapView(context).apply {
|
MapView(context).apply {
|
||||||
setMultiTouchControls(true)
|
setMultiTouchControls(true)
|
||||||
setUseDataConnection(false)
|
setUseDataConnection(false)
|
||||||
setTileSource(tileSource)
|
setTileSource(offlineTileSource)
|
||||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||||
maxZoomLevel = 7.0
|
maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||||
controller.setZoom(minZoomLevel + 2)
|
controller.setZoom(minZoomLevel + 2)
|
||||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||||
|
setHorizontalMapRepetitionEnabled(true)
|
||||||
|
setVerticalMapRepetitionEnabled(false)
|
||||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||||
|
addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
DisposableEffect(lifecycle) {
|
DisposableEffect(lifecycle) {
|
||||||
lifecycle.addObserver(lifecycleObserver)
|
lifecycle.addObserver(lifecycleObserver)
|
||||||
|
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
mapView.onResume()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||||
}
|
}
|
||||||
return mapView
|
return mapView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun configureOsmdroidCache(context: android.content.Context) {
|
||||||
|
val cacheRoot = File(context.filesDir, "osmdroid")
|
||||||
|
val tileCache = File(cacheRoot, "tiles")
|
||||||
|
Configuration.getInstance().apply {
|
||||||
|
setUserAgentValue(TILE_USER_AGENT)
|
||||||
|
setOsmdroidBasePath(cacheRoot)
|
||||||
|
setOsmdroidTileCache(tileCache)
|
||||||
|
setTileFileSystemCacheMaxBytes(TILE_CACHE_MAX_BYTES)
|
||||||
|
setTileFileSystemCacheTrimBytes(TILE_CACHE_TRIM_BYTES)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||||
LifecycleEventObserver { _, event ->
|
LifecycleEventObserver { _, event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
mapView.onResume()
|
||||||
|
requestTileRefresh(mapView)
|
||||||
|
}
|
||||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ data class MapState(
|
|||||||
val mapData: MapData? = null,
|
val mapData: MapData? = null,
|
||||||
val isLightUi: Boolean = false,
|
val isLightUi: Boolean = false,
|
||||||
val isUtc: Boolean = false,
|
val isUtc: Boolean = false,
|
||||||
|
val mapSource: String = "",
|
||||||
|
val tiandituKey: String = "",
|
||||||
val stationPosition: GeoPos? = null,
|
val stationPosition: GeoPos? = null,
|
||||||
val orbitalPass: OrbitalPass,
|
val orbitalPass: OrbitalPass,
|
||||||
val track: List<List<GeoPos>>? = null,
|
val track: List<List<GeoPos>>? = null,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
import com.rtbishop.look4sat.core.domain.utility.clipLat
|
||||||
import com.rtbishop.look4sat.core.domain.utility.clipLon
|
import com.rtbishop.look4sat.core.domain.utility.clipLon
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
import com.rtbishop.look4sat.core.domain.utility.toTimerString
|
||||||
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
import com.rtbishop.look4sat.core.presentation.getDefaultPass
|
||||||
@@ -60,6 +61,8 @@ class MapViewModel(
|
|||||||
MapState(
|
MapState(
|
||||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
|
mapSource = settingsRepo.otherSettings.value.mapSource,
|
||||||
|
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
|
||||||
orbitalPass = defaultPass
|
orbitalPass = defaultPass
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -73,7 +76,13 @@ class MapViewModel(
|
|||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.otherSettings.collectLatest { settings ->
|
settingsRepo.otherSettings.collectLatest { settings ->
|
||||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isUtc = settings.stateOfUtc,
|
||||||
|
mapSource = settings.mapSource,
|
||||||
|
tiandituKey = settings.tiandituKey
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||||
@@ -166,9 +175,7 @@ class MapViewModel(
|
|||||||
var localSelectedPos: OrbitalPos? = null
|
var localSelectedPos: OrbitalPos? = null
|
||||||
for (satellite in chunk) {
|
for (satellite in chunk) {
|
||||||
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
val satPos = satelliteRepo.getPosition(satellite, pos, date.time)
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
localPositions.add(satellite to satPos.toMapGeoPos())
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
localPositions.add(satellite to GeoPos(osmLat, osmLon))
|
|
||||||
if (satellite === selected) {
|
if (satellite === selected) {
|
||||||
localSelectedPos = satPos
|
localSelectedPos = satPos
|
||||||
}
|
}
|
||||||
@@ -242,9 +249,7 @@ class MapViewModel(
|
|||||||
}
|
}
|
||||||
val azimuth = satPos.azimuth.toDegrees()
|
val azimuth = satPos.azimuth.toDegrees()
|
||||||
val elevation = satPos.elevation.toDegrees()
|
val elevation = satPos.elevation.toDegrees()
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
val osmPos = satPos.toMapGeoPos()
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
|
||||||
val osmPos = GeoPos(osmLat, osmLon)
|
|
||||||
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
val qthLoc = positionToQth(osmPos.latitude, osmPos.longitude) ?: "-- --"
|
||||||
val velocity = satPos.getOrbitalVelocity()
|
val velocity = satPos.getOrbitalVelocity()
|
||||||
val phase = satPos.phase.toDegrees()
|
val phase = satPos.phase.toDegrees()
|
||||||
@@ -272,31 +277,45 @@ class MapViewModel(
|
|||||||
val satTracks = mutableListOf<List<GeoPos>>()
|
val satTracks = mutableListOf<List<GeoPos>>()
|
||||||
val currentTrack = mutableListOf<GeoPos>()
|
val currentTrack = mutableListOf<GeoPos>()
|
||||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||||
var oldLongitude = 0.0
|
var previousPosition: GeoPos? = null
|
||||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||||
val osmLat = clipLat(satPos.latitude.toDegrees())
|
val currentPosition = satPos.toMapGeoPos()
|
||||||
val osmLon = clipLon(satPos.longitude.toDegrees())
|
val previous = previousPosition
|
||||||
val currentPosition = GeoPos(osmLat, osmLon)
|
if (previous != null && previous.longitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
|
||||||
// adding left terminal position
|
// adding left terminal position
|
||||||
currentTrack.add(GeoPos(osmLat, -180.0))
|
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||||
|
} else if (previous != null && previous.longitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||||
// adding right terminal position
|
// adding right terminal position
|
||||||
currentTrack.add(GeoPos(osmLat, 180.0))
|
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||||
satTracks.add(finishedTrack)
|
satTracks.add(finishedTrack)
|
||||||
currentTrack.clear()
|
currentTrack.clear()
|
||||||
|
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||||
}
|
}
|
||||||
oldLongitude = currentPosition.longitude
|
previousPosition = currentPosition
|
||||||
currentTrack.add(currentPosition)
|
currentTrack.add(currentPosition)
|
||||||
}
|
}
|
||||||
satTracks.add(currentTrack)
|
satTracks.add(currentTrack)
|
||||||
_uiState.update { it.copy(track = satTracks) }
|
_uiState.update { it.copy(track = satTracks) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos): Double {
|
||||||
|
val currentLon = if (previous.longitude < 0.0 && current.longitude > 0.0) {
|
||||||
|
current.longitude - 360.0
|
||||||
|
} else {
|
||||||
|
current.longitude + 360.0
|
||||||
|
}
|
||||||
|
val edgeLon = if (previous.longitude < 0.0) -180.0 else 180.0
|
||||||
|
val fraction = (edgeLon - previous.longitude) / (currentLon - previous.longitude)
|
||||||
|
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Number of parallel chunks for satellite position computation */
|
/** Number of parallel chunks for satellite position computation */
|
||||||
private const val PARALLEL_CHUNKS = 4
|
private const val PARALLEL_CHUNKS = 4
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class RadarViewModel(
|
|||||||
private val _uiState = MutableStateFlow(
|
private val _uiState = MutableStateFlow(
|
||||||
RadarState(
|
RadarState(
|
||||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||||
orientationValues = sensorsRepo.orientation.value,
|
orientationValues = sensorsRepo.sensorData.value,
|
||||||
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
shouldShowSweep = settingsRepo.otherSettings.value.stateOfSweep,
|
||||||
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
shouldUseCompass = settingsRepo.otherSettings.value.stateOfSensors,
|
||||||
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
sstv = SstvSubState(selectedMode = settingsRepo.otherSettings.value.sstvMode)
|
||||||
@@ -97,7 +97,7 @@ class RadarViewModel(
|
|||||||
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
if (!settingsRepo.otherSettings.value.stateOfSensors) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sensorsRepo.enableSensor()
|
sensorsRepo.enableSensor()
|
||||||
sensorsRepo.orientation.collect { data ->
|
sensorsRepo.sensorData.collect { data ->
|
||||||
val orientationValues = (data.first + magDeclination) to data.second
|
val orientationValues = (data.first + magDeclination) to data.second
|
||||||
_uiState.update { it.copy(orientationValues = orientationValues) }
|
_uiState.update { it.copy(orientationValues = orientationValues) }
|
||||||
}
|
}
|
||||||
@@ -320,10 +320,8 @@ class RadarViewModel(
|
|||||||
|
|
||||||
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) {
|
||||||
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time)
|
||||||
val isFreqEnabled =
|
|
||||||
settingsRepo.rcSettings.value.frequencyState || settingsRepo.rcSettings.value.bluetoothFrequencyState
|
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
val freq = if (isFreqEnabled && state.transceivers.selectedUuid != null) {
|
val freq = if (state.transceivers.selectedUuid != null) {
|
||||||
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
|
||||||
selectedRadio?.let { radio ->
|
selectedRadio?.let { radio ->
|
||||||
val low = radio.downlinkLow
|
val low = radio.downlinkLow
|
||||||
|
|||||||
@@ -190,16 +190,14 @@ internal fun SstvPage(
|
|||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
// Doppler-corrected downlink frequency hint
|
// Doppler-corrected downlink frequency hint
|
||||||
if (dopplerFrequency != null) {
|
OutlinedText(
|
||||||
OutlinedText(
|
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected",
|
||||||
text = "RX: $dopplerFrequency Hz",
|
fontSize = 18.sp,
|
||||||
fontSize = 18.sp,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.Bold,
|
fillColor = MaterialTheme.colorScheme.primary,
|
||||||
fillColor = MaterialTheme.colorScheme.primary,
|
outlineColor = MaterialTheme.colorScheme.background,
|
||||||
outlineColor = MaterialTheme.colorScheme.background,
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
|||||||
+128
-5
@@ -34,10 +34,14 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -56,11 +60,13 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
@@ -91,7 +97,8 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
val permissions = rememberSettingsPermissions(
|
val permissions = rememberSettingsPermissions(
|
||||||
sendAction = onAction,
|
sendAction = onAction,
|
||||||
onBluetoothGranted = { dialogs.bluetooth = true }
|
onBluetoothGranted = { dialogs.bluetooth = true },
|
||||||
|
onNetworkGranted = { dialogs.network = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
@@ -273,12 +280,13 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
}
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
OutputCard(
|
OutputCard(
|
||||||
onNetworkClick = { dialogs.network = true },
|
onNetworkClick = permissions.launchNetwork,
|
||||||
onBluetoothClick = permissions.launchBluetooth,
|
onBluetoothClick = permissions.launchBluetooth,
|
||||||
onRadioControlClick = { dialogs.radioControl = true }
|
onRadioControlClick = { dialogs.radioControl = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
|
item { MapSettingsCard(uiState.otherSettings, onAction) }
|
||||||
item { CardCredits() }
|
item { CardCredits() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,6 +469,106 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
OtherCard(settings = values) {}
|
OtherCard(settings = values) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapSettingsCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
|
var keyValue by remember(settings.tiandituKey) { mutableStateOf(settings.tiandituKey) }
|
||||||
|
LaunchedEffect(keyValue) {
|
||||||
|
kotlinx.coroutines.delay(400)
|
||||||
|
if (keyValue.trim() != settings.tiandituKey) {
|
||||||
|
onAction(SettingsAction.SetTiandituKey(keyValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_map_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
val currentMapSource = MapSource.normalize(settings.mapSource)
|
||||||
|
MapSourceRow(
|
||||||
|
currentMapSource = currentMapSource,
|
||||||
|
firstSource = MapSource.OSM,
|
||||||
|
firstTitle = stringResource(id = R.string.prefs_map_source_osm),
|
||||||
|
secondSource = MapSource.TIANDITU_VECTOR,
|
||||||
|
secondTitle = stringResource(id = R.string.prefs_map_source_tianditu_vector),
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
MapSourceRow(
|
||||||
|
currentMapSource = currentMapSource,
|
||||||
|
firstSource = MapSource.TIANDITU_IMAGE,
|
||||||
|
firstTitle = stringResource(id = R.string.prefs_map_source_tianditu_image),
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = keyValue,
|
||||||
|
onValueChange = { keyValue = it },
|
||||||
|
label = { Text(text = stringResource(id = R.string.prefs_map_tianditu_key)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(SettingsAction.SetTiandituKey(keyValue)) },
|
||||||
|
text = stringResource(id = R.string.prefs_map_save_key),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapSourceRow(
|
||||||
|
currentMapSource: String,
|
||||||
|
firstSource: String,
|
||||||
|
firstTitle: String,
|
||||||
|
secondSource: String? = null,
|
||||||
|
secondTitle: String? = null,
|
||||||
|
onAction: (SettingsAction) -> Unit
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
MapSourceButton(
|
||||||
|
onClick = { onAction(SettingsAction.SetMapSource(firstSource)) },
|
||||||
|
text = firstTitle,
|
||||||
|
selected = currentMapSource == firstSource,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (secondSource != null && secondTitle != null) {
|
||||||
|
MapSourceButton(
|
||||||
|
onClick = { onAction(SettingsAction.SetMapSource(secondSource)) },
|
||||||
|
text = secondTitle,
|
||||||
|
selected = currentMapSource == secondSource,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MapSourceButton(onClick: () -> Unit, text: String, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
ElevatedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
@@ -651,13 +759,15 @@ private class SettingsPermissions(
|
|||||||
val launchLocation: () -> Unit,
|
val launchLocation: () -> Unit,
|
||||||
val launchTleImport: () -> Unit,
|
val launchTleImport: () -> Unit,
|
||||||
val launchTransceiverImport: () -> Unit,
|
val launchTransceiverImport: () -> Unit,
|
||||||
val launchBluetooth: () -> Unit
|
val launchBluetooth: () -> Unit,
|
||||||
|
val launchNetwork: () -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberSettingsPermissions(
|
private fun rememberSettingsPermissions(
|
||||||
sendAction: (SettingsAction) -> Unit,
|
sendAction: (SettingsAction) -> Unit,
|
||||||
onBluetoothGranted: () -> Unit
|
onBluetoothGranted: () -> Unit,
|
||||||
|
onNetworkGranted: () -> Unit
|
||||||
): SettingsPermissions {
|
): SettingsPermissions {
|
||||||
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
val locationError = stringResource(R.string.prefs_loc_gps_error)
|
||||||
val locationRequest = rememberLauncherForActivityResult(
|
val locationRequest = rememberLauncherForActivityResult(
|
||||||
@@ -685,6 +795,12 @@ private fun rememberSettingsPermissions(
|
|||||||
else sendAction(SettingsAction.ShowToast(bluetoothError))
|
else sendAction(SettingsAction.ShowToast(bluetoothError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val networkError = stringResource(R.string.prefs_net_perm_error)
|
||||||
|
val networkRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) onNetworkGranted()
|
||||||
|
else sendAction(SettingsAction.ShowToast(networkError))
|
||||||
|
}
|
||||||
|
|
||||||
return remember {
|
return remember {
|
||||||
SettingsPermissions(
|
SettingsPermissions(
|
||||||
launchLocation = {
|
launchLocation = {
|
||||||
@@ -694,7 +810,14 @@ private fun rememberSettingsPermissions(
|
|||||||
},
|
},
|
||||||
launchTleImport = { tleRequest.launch("*/*") },
|
launchTleImport = { tleRequest.launch("*/*") },
|
||||||
launchTransceiverImport = { transceiversRequest.launch("*/*") },
|
launchTransceiverImport = { transceiversRequest.launch("*/*") },
|
||||||
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) }
|
launchBluetooth = { bluetoothRequest.launch(bluetoothPerm) },
|
||||||
|
launchNetwork = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||||
|
networkRequest.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
} else {
|
||||||
|
onNetworkGranted()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ sealed interface SettingsAction {
|
|||||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||||
|
data class SetMapSource(val value: String) : SettingsAction
|
||||||
|
data class SetTiandituKey(val value: String) : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+2
@@ -121,6 +121,8 @@ class SettingsViewModel(
|
|||||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||||
|
is SettingsAction.SetMapSource -> settingsRepo.updateOtherSettings { it.copy(mapSource = action.value) }
|
||||||
|
is SettingsAction.SetTiandituKey -> settingsRepo.updateOtherSettings { it.copy(tiandituKey = action.value.trim()) }
|
||||||
// Remote control & data sources
|
// Remote control & data sources
|
||||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ org.gradle.caching=true
|
|||||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
org.gradle.workers.max=4
|
org.gradle.workers.max=4
|
||||||
|
|
||||||
|
# Enabled parallel sync for Gradle 9.4+
|
||||||
|
org.gradle.tooling.parallel=true
|
||||||
+12
-12
@@ -1,35 +1,35 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "441"
|
appVersionCode = "443"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.1"
|
appVersionName = "4.4.3"
|
||||||
#noinspection GradleDependency,UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "36"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
minSdk = "24"
|
minSdk = "24"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
jdkVersion = "17"
|
jdkVersion = "21"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
packageName = "com.rtbishop.look4sat"
|
packageName = "cn.bh2vsq.look4sat"
|
||||||
|
|
||||||
android-gradle-plugin = "9.2.1"
|
android-gradle-plugin = "9.2.1"
|
||||||
|
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-core-splashscreen = "1.2.0"
|
androidx-core-splashscreen = "1.2.0"
|
||||||
androidx-room = "2.8.4"
|
androidx-room = "2.8.4"
|
||||||
|
|
||||||
compose-bom = "2026.05.01"
|
compose-bom = "2026.06.00"
|
||||||
compose-activity = "1.13.0"
|
compose-activity = "1.13.0"
|
||||||
compose-lifecycle = "2.10.0"
|
compose-lifecycle = "2.11.0"
|
||||||
compose-navigation3 = "1.1.2"
|
compose-navigation3 = "1.1.3"
|
||||||
|
|
||||||
google-ksp = "2.3.7"
|
google-ksp = "2.3.8"
|
||||||
|
|
||||||
kotlin = "2.4.0"
|
kotlin = "2.4.0"
|
||||||
kotlin-coroutines = "1.11.0"
|
kotlin-coroutines = "1.11.0"
|
||||||
kotlin-serialization = "1.11.0"
|
kotlin-serialization = "1.11.0"
|
||||||
|
|
||||||
other-okhttp = "5.3.2"
|
other-okhttp = "5.4.0"
|
||||||
other-osmdroid = "6.1.20"
|
other-osmdroid = "6.1.20"
|
||||||
|
|
||||||
test-junit4 = "4.13.2"
|
test-junit4 = "4.13.2"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#Mon May 25 11:20:48 BST 2026
|
#Sat Jun 27 12:51:12 BST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.6.1/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in new issue
Block a user