Compare commits

...
16 Commits
Author SHA1 Message Date
Arty Bishop 782b438a07 v4.4.8 - Fuzzy satellites search, light theme, various fixes 2026-09-27 14:23:02 +01:00
Michael S fac319b215 Fixed IC-705 frequency read in dual-radio mode (#260) 2026-09-27 10:45:21 +01:00
Ondrej KolonicnyandClaude Opus 4.8 b7ac9c913a Added light theme option suitable for e-ink devices (#256)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-19 11:35:42 +01:00
atsunatsuandatsunatsu de3d5a6d84 Added fuzzy satellite name search ignoring separators (#255)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-19 11:13:39 +01:00
atsunatsuandatsunatsu 5e39c945b2 Applied user elevation color thresholds to the radar page (#253)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-19 11:10:50 +01:00
wty2019wtyanddependabot[bot] 1d07f76ada Fixed the AOS window filter so it follows the UTC setting (#252)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 11:03:05 +01:00
Arty Bishop d471f02370 v4.4.7 - Data import overhaul, AMSAT reports, Map tweaks 2026-09-13 20:26:27 +01:00
Arty Bishop 9699d142fa Fixed RadarScreen padding, implemented basic Detekt setup 2026-09-13 17:29:38 +01:00
Arty Bishop 6f3e3351c9 Tweaked data source management, migrated DB and Settings 2026-09-13 15:19:23 +01:00
Arty Bishop d4a2fae0cd Updated dependencies and gradle plugin, minor fixes 2026-09-12 11:40:59 +01:00
dependabot[bot] d0bbb97f29 Bump actions/setup-java from 5 to 6 (#249)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 08:40:23 +01:00
atsunatsuandatsunatsu c2fc5688b9 Added AMSAT satellite status reports submission (#248)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-03 08:39:34 +01:00
TianhengZhuang 796056a582 Extracted hardcoded UI strings to resources (en/zh) (#247) 2026-09-02 09:08:37 +01:00
atsunatsuandatsunatsu 9cac33dae7 Localized AMSAT status screen for Chinese (#245)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-09-02 09:05:45 +01:00
wty2019wty 082266e385 Overhauled data source management, added HTTP status (#242) 2026-08-21 14:11:30 +02:00
atsunatsuandatsunatsu a42a5f1f0d Tweaked sunrise/sunset calculations, added unit tests (#241)
Co-authored-by: atsunatsu <atsunatsu@users.noreply.github.com>
2026-08-19 14:46:39 +02:00
65 changed files with 3812 additions and 481 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v5 uses: actions/setup-java@v6
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -27,6 +27,8 @@ import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -48,8 +50,10 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
observeNightFilterState() observeNightFilterState()
val settingsRepo = (applicationContext as IContainerProvider).getMainContainer().settingsRepo
setContent { setContent {
MainTheme(isDarkTheme = true) { NavRoot() } val otherSettings by settingsRepo.otherSettings.collectAsState()
MainTheme(isDarkTheme = !otherSettings.stateOfLightTheme) { NavRoot() }
} }
} }
@@ -68,6 +68,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState import com.rtbishop.look4sat.core.domain.repository.RadioTrackingState
import com.rtbishop.look4sat.core.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
@@ -98,29 +99,43 @@ fun NavRoot(deeplink: String? = null) {
// Reverse: outgoing slides out to the right, incoming drifts in from the left // Reverse: outgoing slides out to the right, incoming drifts in from the left
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
slideOutHorizontally(tween(300)) { it } slideOutHorizontally(tween(300)) { it }
NavDisplay( // Elevation color thresholds must be provided at the root level so that BOTH
modifier = Modifier.fillMaxSize(), // the tab content (MainScreen) and the root-level RadarDestination see the
backStack = rootBackStack, // user's custom thresholds. RadarDestination is a sibling entry of MainScreen
onBack = navigateBack, // in this NavDisplay, so a provider inside MainScreen never reaches it.
transitionSpec = { pushTransition }, val context = LocalContext.current
popTransitionSpec = { popTransition }, val container = (context.applicationContext as IContainerProvider).getMainContainer()
predictivePopTransitionSpec = { popTransition }, val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
entryDecorators = listOf( CompositionLocalProvider(
rememberSaveableStateHolderNavEntryDecorator(), LocalElevationThresholds provides ElevationThresholds(
rememberViewModelStoreNavEntryDecorator() low = otherSettings.lowElevation,
), high = otherSettings.highElevation
entryProvider = entryProvider { )
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) } ) {
entry<RadarDestination> { NavDisplay(
Surface( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), backStack = rootBackStack,
color = MaterialTheme.colorScheme.background onBack = navigateBack,
) { transitionSpec = { pushTransition },
RadarDestination(navigateUp = navigateBack) popTransitionSpec = { popTransition },
predictivePopTransitionSpec = { popTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Passes> { MainScreen(navigateToRadar = navigateToRadar) }
entry<RadarDestination> {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
RadarDestination(navigateUp = navigateBack)
}
} }
} }
} )
) }
} }
@Composable @Composable
@@ -128,7 +143,6 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle() val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
@@ -138,88 +152,81 @@ private fun MainScreen(navigateToRadar: () -> Unit = {}) {
val navItems = val navItems =
listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings) listOf(Screen.Satellites, Screen.Passes, Screen.Status, Screen.Map, Screen.Settings)
CompositionLocalProvider( NavigationSuiteScaffold(
LocalElevationThresholds provides ElevationThresholds( navigationSuiteItems = {
low = otherSettings.lowElevation, navItems.forEach { screen ->
high = otherSettings.highElevation val isSelected = when (currentKey) {
) is Screen.Satellites -> screen is Screen.Satellites
) { is Screen.Passes -> screen is Screen.Passes
NavigationSuiteScaffold( is Screen.Status -> screen is Screen.Status
navigationSuiteItems = { is Screen.Map -> screen is Screen.Map
navItems.forEach { screen -> is Screen.Settings -> screen is Screen.Settings
val isSelected = when (currentKey) { else -> false
is Screen.Satellites -> screen is Screen.Satellites
is Screen.Passes -> screen is Screen.Passes
is Screen.Status -> screen is Screen.Status
is Screen.Map -> screen is Screen.Map
is Screen.Settings -> screen is Screen.Settings
else -> false
}
item(
icon = {
Icon(
painter = painterResource(screen.iconResId),
contentDescription = stringResource(screen.titleResId)
)
},
label = { Text(stringResource(screen.titleResId)) },
selected = isSelected,
onClick = {
if (isSelected) return@item
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
if (screen !is Screen.Passes) backStack.add(screen)
}
)
} }
}, item(
navigationSuiteColors = NavigationSuiteDefaults.colors( icon = {
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer Icon(
), painter = painterResource(screen.iconResId),
layoutType = when { contentDescription = stringResource(screen.titleResId)
!hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail )
!hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact },
else -> NavigationSuiteType.ShortNavigationBarMedium label = { Text(stringResource(screen.titleResId)) },
} selected = isSelected,
) { onClick = {
Column { if (isSelected) return@item
NavDisplay( while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
backStack = backStack, if (screen !is Screen.Passes) backStack.add(screen)
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
}
}
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
} }
) )
if (trackingState.isActive) { }
TrackingBanner( },
state = trackingState, navigationSuiteColors = NavigationSuiteDefaults.colors(
onClick = { navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
val pass = trackingState.currentPass ),
if (pass != null) { layoutType = when {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) !hasEnoughHeight() && hasEnoughWidth() -> NavigationSuiteType.NavigationRail
navigateToRadar() !hasEnoughWidth() -> NavigationSuiteType.ShortNavigationBarCompact
} else -> NavigationSuiteType.ShortNavigationBarMedium
}
) {
Column {
NavDisplay(
backStack = backStack,
modifier = Modifier.weight(1f),
onBack = navigateBack,
transitionSpec = { fadeTransition },
popTransitionSpec = { fadeTransition },
predictivePopTransitionSpec = { fadeTransition },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Satellites> {
SatellitesDestination(navigateUp = navigateBack)
}
entry<Screen.Passes> {
PassesDestination { catNum, aosTime ->
container.satelliteRepo.selectPass(catNum, aosTime)
navigateToRadar()
} }
) }
entry<Screen.Status> { SatStatusDestination() }
entry<Screen.Map> { MapDestination() }
entry<Screen.Settings> { SettingsDestination() }
} }
)
if (trackingState.isActive) {
TrackingBanner(
state = trackingState,
onClick = {
val pass = trackingState.currentPass
if (pass != null) {
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
navigateToRadar()
}
}
)
} }
} }
} }
@@ -252,7 +259,7 @@ private fun TrackingBanner(state: RadioTrackingState, onClick: () -> Unit) {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Tracking: ${state.currentPass?.name ?: ""}", text = stringResource(R.string.tracking_format, state.currentPass?.name ?: ""),
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,
+40
View File
@@ -5,6 +5,46 @@ plugins {
alias(libs.plugins.google.ksp) apply false alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.other.detekt)
}
val detektCheck = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektCheck") {
description = "Checks that source code satisfies detekt rules."
autoCorrect = false
}
val detektApply = tasks.register<io.gitlab.arturbosch.detekt.Detekt>("detektApply") {
description = "Applies code formatting rules to source code in-place."
autoCorrect = true
}
configure(listOf(detektCheck, detektApply)) {
configure {
group = "verification"
parallel = true
ignoreFailures = false
setSource(file(rootDir))
// Custom detekt config
config.setFrom("$projectDir/config/detekt/detekt.yml")
// Use default configuration on top of custom config
// (new detect rules will work out of the box after upgrading detekt version)
buildUponDefaultConfig = true
// Runs detekt for all files in the Gradle project and all subprojects without
// a need to configure detekt plugin in every subproject.
include("**/*.kt", "**/*.kts")
exclude("**/resources/**", "**/build/**", "**/generated/**", "**/testing/**")
reports {
html.required.set(true)
xml.required.set(true)
}
}
dependencies {
detektPlugins(libs.other.detekt.formatting)
}
} }
tasks.register("clean", Delete::class.java) { tasks.register("clean", Delete::class.java) {
+808
View File
@@ -0,0 +1,808 @@
build:
maxIssues: 0
excludeCorrectable: false
weights:
# complexity: 2
# LongParameterList: 1
# style: 1
# comments: 1
config:
validation: true
warningsAsErrors: false
checkExhaustiveness: false
# when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'
excludes: ''
processors:
active: true
exclude:
- 'DetektProgressListener'
# - 'KtFileCountProcessor'
# - 'PackageCountProcessor'
# - 'ClassCountProcessor'
# - 'FunctionCountProcessor'
# - 'PropertyCountProcessor'
# - 'ProjectComplexityProcessor'
# - 'ProjectCognitiveComplexityProcessor'
# - 'ProjectLLOCProcessor'
# - 'ProjectCLOCProcessor'
# - 'ProjectLOCProcessor'
# - 'ProjectSLOCProcessor'
# - 'LicenseHeaderLoaderExtension'
console-reports:
active: true
exclude:
- 'ProjectStatisticsReport'
- 'ComplexityReport'
- 'NotificationReport'
- 'FindingsReport'
- 'FileBasedFindingsReport'
# - 'LiteFindingsReport'
output-reports:
active: true
exclude:
# - 'TxtOutputReport'
# - 'XmlOutputReport'
- 'HtmlOutputReport'
# - 'MdOutputReport'
# - 'SarifOutputReport'
comments:
active: true
AbsentOrWrongFileLicense:
active: false
licenseTemplateFile: 'license.template'
licenseTemplateIsRegex: false
CommentOverPrivateFunction:
active: false
CommentOverPrivateProperty:
active: false
DeprecatedBlockTag:
active: false
EndOfSentenceFormat:
active: false
endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)'
KDocReferencesNonPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
OutdatedDocumentation:
active: false
matchTypeParameters: true
matchDeclarationsOrder: true
allowParamOnConstructorProperties: false
UndocumentedPublicClass:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchInNestedClass: true
searchInInnerClass: true
searchInInnerObject: true
searchInInnerInterface: true
searchInProtectedClass: false
UndocumentedPublicFunction:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedFunction: false
UndocumentedPublicProperty:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
searchProtectedProperty: false
complexity:
active: true
CognitiveComplexMethod:
active: false
threshold: 15
ComplexCondition:
active: true
threshold: 4
ComplexInterface:
active: false
threshold: 10
includeStaticDeclarations: false
includePrivateDeclarations: false
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
nestingFunctions:
- 'also'
- 'apply'
- 'forEach'
- 'isNotNull'
- 'ifNull'
- 'let'
- 'run'
- 'use'
- 'with'
# Composables describe UI trees declaratively and naturally accumulate more branches than
# regular functions. Exclude them here and rely on code review + decomposition instead.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
LabeledExpression:
active: false
ignoredLabels: [ ]
LargeClass:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 600
LongMethod:
active: true
# Composable UI functions are declarative trees; 60 lines is too strict for them.
# Screen/Slots files are excluded — keep them reasonable via code review.
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*Screen.kt', '**/*Slots.kt' ]
threshold: 60
LongParameterList:
active: false
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: false
ignoreDataClasses: true
ignoreAnnotatedParameter: [ ]
MethodOverloading:
active: false
threshold: 6
NamedArguments:
active: false
threshold: 3
ignoreArgumentsMatchingNames: false
NestedBlockDepth:
active: true
threshold: 4
NestedScopeFunctions:
active: false
threshold: 1
functions:
- 'kotlin.apply'
- 'kotlin.run'
- 'kotlin.with'
- 'kotlin.let'
- 'kotlin.also'
ReplaceSafeCallChainWithRun:
active: false
StringLiteralDuplication:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
threshold: 3
ignoreAnnotation: true
excludeStringsWithLessThan5Characters: true
ignoreStringsRegex: '$^'
TooManyFunctions:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignoreOverridden: false
coroutines:
active: true
GlobalCoroutineUsage:
active: false
InjectDispatcher:
active: true
dispatcherNames:
- 'IO'
- 'Default'
- 'Unconfined'
RedundantSuspendModifier:
active: true
SleepInsteadOfDelay:
active: true
SuspendFunSwallowedCancellation:
active: false
SuspendFunWithCoroutineScopeReceiver:
active: false
SuspendFunWithFlowReturnType:
active: true
empty-blocks:
active: true
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: '_|(ignore|expected).*'
EmptyClassBlock:
active: true
EmptyDefaultConstructor:
active: true
EmptyDoWhileBlock:
active: true
EmptyElseBlock:
active: true
EmptyFinallyBlock:
active: true
EmptyForBlock:
active: true
EmptyFunctionBlock:
active: true
ignoreOverridden: true
EmptyIfBlock:
active: true
EmptyInitBlock:
active: true
EmptyKtFile:
active: true
EmptySecondaryConstructor:
active: true
EmptyTryBlock:
active: true
EmptyWhenBlock:
active: true
EmptyWhileBlock:
active: true
exceptions:
active: true
ExceptionRaisedInUnexpectedLocation:
active: true
methodNames:
- 'equals'
- 'finalize'
- 'hashCode'
- 'toString'
InstanceOfCheckForException:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
NotImplementedDeclaration:
active: false
ObjectExtendsThrowable:
active: false
PrintStackTrace:
active: true
RethrowCaughtException:
active: true
ReturnFromFinally:
active: true
ignoreLabeled: false
SwallowedException:
active: true
ignoredExceptionTypes:
- 'InterruptedException'
- 'MalformedURLException'
- 'NumberFormatException'
- 'ParseException'
allowedExceptionNameRegex: '_|(ignore|expected).*'
ThrowingExceptionFromFinally:
active: true
ThrowingExceptionInMain:
active: false
ThrowingExceptionsWithoutMessageOrCause:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptions:
- 'ArrayIndexOutOfBoundsException'
- 'Exception'
- 'IllegalArgumentException'
- 'IllegalMonitorStateException'
- 'IllegalStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
ThrowingNewInstanceOfSameException:
active: true
TooGenericExceptionCaught:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
exceptionNames:
- 'ArrayIndexOutOfBoundsException'
- 'Error'
- 'Exception'
- 'IllegalMonitorStateException'
- 'IndexOutOfBoundsException'
- 'NullPointerException'
- 'RuntimeException'
- 'Throwable'
allowedExceptionNameRegex: '_|(ignore|expected).*'
TooGenericExceptionThrown:
active: true
exceptionNames:
- 'Error'
- 'Exception'
- 'RuntimeException'
- 'Throwable'
formatting:
active: true
ArgumentListWrapping:
active: false
ParameterListWrapping:
active: false
NoWildcardImports:
active: false
MaximumLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
naming:
active: true
BooleanPropertyNaming:
active: false
allowedPattern: '^(is|has|are)'
ClassNaming:
active: true
classPattern: '[A-Z][a-zA-Z0-9_]*'
ConstructorParameterNaming:
active: true
parameterPattern: '[a-z][A-Za-z0-9]*'
privateParameterPattern: '[a-z][A-Za-z0-9]*'
excludeClassPattern: '$^'
EnumNaming:
active: true
enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'
ForbiddenClassName:
active: false
forbiddenName: [ ]
FunctionMaxLength:
active: false
maximumFunctionNameLength: 30
FunctionMinLength:
active: false
minimumFunctionNameLength: 3
FunctionNaming:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
excludeClassPattern: '$^'
FunctionParameterNaming:
active: true
parameterPattern: '[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
InvalidPackageDeclaration:
active: true
rootPackage: ''
requireRootInDeclaration: false
LambdaParameterNaming:
active: false
parameterPattern: '[a-z][A-Za-z0-9]*|_'
MatchingDeclarationName:
active: false
mustBeFirst: false
MemberNameEqualsClassName:
active: true
ignoreOverridden: true
NoNameShadowing:
active: true
NonBooleanPropertyPrefixedWithIs:
active: false
ObjectPropertyNaming:
active: true
constantPattern: '[A-Za-z][_A-Za-z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'
PackageNaming:
active: true
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
TopLevelPropertyNaming:
active: true
constantPattern: '[A-Z][_A-Z0-9]*'
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
VariableMaxLength:
active: false
maximumVariableNameLength: 64
VariableMinLength:
active: false
minimumVariableNameLength: 1
VariableNaming:
active: true
variablePattern: '[a-zA-Z][A-Za-z0-9]*'
privateVariablePattern: '(_)?[a-zA-Z][A-Za-z0-9]*'
excludeClassPattern: '$^'
performance:
active: true
ArrayPrimitive:
active: true
CouldBeSequence:
active: false
threshold: 3
ForEachOnRange:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
SpreadOperator:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnnecessaryPartOfBinaryExpression:
active: false
UnnecessaryTemporaryInstantiation:
active: true
potential-bugs:
active: true
AvoidReferentialEquality:
active: true
forbiddenTypePatterns:
- 'kotlin.String'
CastNullableToNonNullableType:
active: false
CastToNullableType:
active: false
Deprecation:
active: false
DontDowncastCollectionTypes:
active: false
DoubleMutabilityForCollection:
active: true
mutableTypes:
- 'kotlin.collections.MutableList'
- 'kotlin.collections.MutableMap'
- 'kotlin.collections.MutableSet'
- 'java.util.ArrayList'
- 'java.util.LinkedHashSet'
- 'java.util.HashSet'
- 'java.util.LinkedHashMap'
- 'java.util.HashMap'
ElseCaseInsteadOfExhaustiveWhen:
active: false
ignoredSubjectTypes: [ ]
EqualsAlwaysReturnsTrueOrFalse:
active: true
EqualsWithHashCodeExist:
active: true
ExitOutsideMain:
active: false
ExplicitGarbageCollectionCall:
active: true
HasPlatformType:
active: true
IgnoredReturnValue:
active: true
restrictToConfig: true
returnValueAnnotations:
- 'CheckResult'
- '*.CheckResult'
- 'CheckReturnValue'
- '*.CheckReturnValue'
ignoreReturnValueAnnotations:
- 'CanIgnoreReturnValue'
- '*.CanIgnoreReturnValue'
returnValueTypes:
- 'kotlin.sequences.Sequence'
- 'kotlinx.coroutines.flow.*Flow'
- 'java.util.stream.*Stream'
ignoreFunctionCall: [ ]
ImplicitDefaultLocale:
active: true
ImplicitUnitReturnType:
active: false
allowExplicitReturnType: true
InvalidRange:
active: true
IteratorHasNextCallsNextMethod:
active: true
IteratorNotThrowingNoSuchElementException:
active: true
LateinitUsage:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
ignoreOnClassesPattern: ''
MapGetWithNotNullAssertionOperator:
active: true
MissingPackageDeclaration:
active: false
excludes: [ '**/*.kts' ]
NullCheckOnMutableProperty:
active: false
NullableToStringCall:
active: false
PropertyUsedBeforeDeclaration:
active: false
UnconditionalJumpStatementInLoop:
active: false
UnnecessaryNotNullCheck:
active: false
UnnecessaryNotNullOperator:
active: true
UnnecessarySafeCall:
active: true
UnreachableCatchBlock:
active: true
UnreachableCode:
active: true
UnsafeCallOnNullableType:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**' ]
UnsafeCast:
active: true
UnusedUnaryOperator:
active: true
UselessPostfixExpression:
active: true
WrongEqualsTypeParameter:
active: true
style:
active: true
AlsoCouldBeApply:
active: false
BracesOnIfStatements:
active: false
singleLine: 'never'
multiLine: 'always'
BracesOnWhenStatements:
active: false
singleLine: 'necessary'
multiLine: 'consistent'
CanBeNonNullable:
active: false
CascadingCallWrapping:
active: false
includeElvis: true
ClassOrdering:
active: false
CollapsibleIfStatements:
active: false
DataClassContainsFunctions:
active: false
conversionFunctionPrefix:
- 'to'
allowOperators: false
DataClassShouldBeImmutable:
active: false
DestructuringDeclarationWithTooManyEntries:
active: true
maxDestructuringEntries: 3
DoubleNegativeLambda:
active: false
negativeFunctions:
- reason: 'Use `takeIf` instead.'
value: 'takeUnless'
- reason: 'Use `all` instead.'
value: 'none'
negativeFunctionNameParts:
- 'not'
- 'non'
EqualsNullCall:
active: true
EqualsOnSignatureLine:
active: false
ExplicitCollectionElementAccessMethod:
active: false
ExplicitItLambdaParameter:
active: true
ExpressionBodySyntax:
active: false
includeLineWrapping: false
ForbiddenAnnotation:
active: false
annotations:
- reason: 'it is a java annotation. Use `Suppress` instead.'
value: 'java.lang.SuppressWarnings'
- reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.'
value: 'java.lang.Deprecated'
- reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.'
value: 'java.lang.annotation.Documented'
- reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.'
value: 'java.lang.annotation.Target'
- reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.'
value: 'java.lang.annotation.Retention'
- reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.'
value: 'java.lang.annotation.Repeatable'
- reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265'
value: 'java.lang.annotation.Inherited'
ForbiddenComment:
active: true
comments:
- reason: 'Forbidden FIXME todo marker in comment, please fix the problem.'
value: 'FIXME:'
- reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.'
value: 'STOPSHIP:'
- reason: 'Forbidden TODO todo marker in comment, please do the changes.'
value: 'TODO:'
allowedPatterns: ''
ForbiddenImport:
active: false
imports: [ ]
forbiddenPatterns: ''
ForbiddenMethodCall:
active: false
methods:
- reason: 'print does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.print'
- reason: 'println does not allow you to configure the output stream. Use a logger instead.'
value: 'kotlin.io.println'
ForbiddenSuppress:
active: false
rules: [ ]
ForbiddenVoid:
active: true
ignoreOverridden: false
ignoreUsageInGenerics: false
FunctionOnlyReturningConstant:
active: true
ignoreOverridableFunction: true
ignoreActualFunction: true
excludedFunctions: [ ]
LoopWithTooManyJumpStatements:
active: true
maxJumpCount: 1
MagicNumber:
active: false
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
ignoreHashCodeFunction: true
ignorePropertyDeclaration: false
ignoreLocalVariableDeclaration: false
ignoreConstantDeclaration: true
ignoreCompanionObjectPropertyDeclaration: true
ignoreAnnotation: false
ignoreNamedArgument: true
ignoreEnums: false
ignoreRanges: false
ignoreExtensionFunctions: true
MandatoryBracesLoops:
active: false
MaxChainedCallsOnSameLine:
active: false
maxChainedCalls: 5
MaxLineLength:
active: true
excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ]
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
excludeCommentStatements: true
excludeRawStrings: true
MayBeConst:
active: true
ModifierOrder:
active: true
MultilineLambdaItParameter:
active: false
MultilineRawStringIndentation:
active: false
indentSize: 4
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
NestedClassesVisibility:
active: true
NewLineAtEndOfFile:
active: true
NoTabs:
active: false
NullableBooleanCheck:
active: false
ObjectLiteralToLambda:
active: true
OptionalAbstractKeyword:
active: true
OptionalUnit:
active: false
OptionalWhenBraces:
active: false
PreferToOverPairSyntax:
active: false
ProtectedMemberInFinalClass:
active: true
RedundantExplicitType:
active: false
RedundantHigherOrderMapUsage:
active: true
RedundantVisibilityModifierRule:
active: false
ReturnCount:
active: true
max: 3
excludedFunctions:
- 'equals'
excludeLabeled: false
excludeReturnFromLambda: true
# Guard clauses (early returns for preconditions) are idiomatic Kotlin and should not
# count toward the return limit. E.g. `if (x == null) return null` at function top.
excludeGuardClauses: true
SafeCast:
active: true
SerialVersionUIDInSerializableClass:
active: true
SpacingBetweenPackageAndImports:
active: false
StringShouldBeRawString:
active: false
maxEscapedCharacterCount: 2
ignoredCharacters: [ ]
ThrowsCount:
active: true
max: 2
excludeGuardClauses: false
TrailingWhitespace:
active: false
TrimMultilineRawString:
active: false
trimmingMethods:
- 'trimIndent'
- 'trimMargin'
UnderscoresInNumericLiterals:
active: false
acceptableLength: 4
allowNonStandardGrouping: false
UnnecessaryAbstractClass:
active: true
UnnecessaryAnnotationUseSiteTarget:
active: false
UnnecessaryApply:
active: true
UnnecessaryBackticks:
active: false
UnnecessaryBracesAroundTrailingLambda:
active: false
UnnecessaryFilter:
active: true
UnnecessaryInheritance:
active: true
UnnecessaryInnerClass:
active: false
UnnecessaryLet:
active: false
UnnecessaryParentheses:
active: false
allowForUnclearPrecedence: false
UntilInsteadOfRangeTo:
active: false
UnusedImports:
active: false
UnusedParameter:
active: true
allowedNames: 'ignored|expected'
UnusedPrivateClass:
active: true
UnusedPrivateMember:
active: true
allowedNames: '[A-Z][a-zA-Z0-9]*Preview'
UnusedPrivateProperty:
active: true
allowedNames: '_|ignored|expected|serialVersionUID'
UseAnyOrNoneInsteadOfFind:
active: true
UseArrayLiteralsInAnnotations:
active: true
UseCheckNotNull:
active: true
UseCheckOrError:
active: true
UseDataClass:
active: false
allowVars: false
UseEmptyCounterpart:
active: false
UseIfEmptyOrIfBlank:
active: false
UseIfInsteadOfWhen:
active: false
ignoreWhenContainingVariableDeclaration: false
UseIsNullOrEmpty:
active: true
UseLet:
active: false
UseOrEmpty:
active: true
UseRequire:
active: true
UseRequireNotNull:
active: true
UseSumOfInsteadOfFlatMapSize:
active: false
UselessCallOnNotNull:
active: true
UtilityClassWithPublicConstructor:
active: true
VarCouldBeVal:
active: true
ignoreLateinitVar: false
WildcardImport:
active: false
excludeImports:
- 'java.util.*'
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@@ -39,6 +40,18 @@ interface Look4SatDao {
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)") @Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry> suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Query("SELECT catnum, epoch FROM entries")
suspend fun getEntriesEpochs(): Map<@MapColumn("catnum") Int, @MapColumn("epoch") Double>
@Query("SELECT catnum, name FROM entries")
suspend fun getEntriesNames(): Map<@MapColumn("catnum") Int, @MapColumn("name") String>
@Query("UPDATE entries SET name = :name WHERE catnum = :catnum")
suspend fun renameEntry(catnum: Int, name: String)
@Query("DELETE FROM entries WHERE catnum IN (:ids)")
suspend fun deleteEntriesWithIds(ids: List<Int>)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>) suspend fun insertEntries(entries: List<SatEntry>)
@@ -65,4 +78,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios") @Query("DELETE FROM radios")
suspend fun deleteRadios() suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
} }
@@ -19,19 +19,23 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false) const val DATABASE_NAME = "Look4SatDBv400"
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() { abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao abstract fun look4SatDao(): Look4SatDao
} }
//val MIGRATION_1_2 = object : Migration(1, 2) { /** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
// override fun migrate(database: SupportSQLiteDatabase) { * transceivers that were imported from a file, so that remote updates leave them alone. */
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))") val MIGRATION_1_2 = object : Migration(1, 2) {
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries") override fun migrate(db: SupportSQLiteDatabase) {
// database.execSQL("DROP TABLE entries") db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries") db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
// } }
//} }
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double, val argper: Double,
val meanan: Double, val meanan: Double,
val catnum: Int, val catnum: Int,
val bstar: Double val bstar: Double,
val ndot: Double = 0.0
) )
@@ -32,5 +32,7 @@ data class SatRadio(
val uplinkHigh: Long?, val uplinkHigh: Long?,
val uplinkMode: String?, val uplinkMode: String?,
val isInverted: Boolean, val isInverted: Boolean,
val catnum: Int? val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
) )
@@ -330,12 +330,13 @@ object IcomCivProtocol {
/** /**
* Parse frequency + mode from a CMD_READ_FREQ reply payload. * Parse frequency + mode from a CMD_READ_FREQ reply payload.
* Payload layout after stripping command byte: [5 freq bytes] [mode byte] [filter byte] * Payload layout after stripping command byte: [5 freq bytes]
* The IC-705 does not include a mode byte; if one is present it is parsed, otherwise mode is empty.
*/ */
fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? { fun parseFreqModePayload(payload: ByteArray): Pair<Long, String>? {
if (payload.size < 6) return null if (payload.size < 5) return null
val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5)) val freqHz = decodeFrequencyBcd(payload.copyOfRange(0, 5))
val mode = BYTE_TO_MODE[payload[5]] ?: return null val mode = if (payload.size > 5) BYTE_TO_MODE[payload[5]] ?: return null else ""
return freqHz to mode return freqHz to mode
} }
@@ -22,7 +22,10 @@ import android.content.Context
import android.hardware.SensorManager import android.hardware.SensorManager
import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager
import android.location.LocationManager import android.location.LocationManager
import androidx.core.content.pm.PackageInfoCompat
import androidx.room.Room import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.DATABASE_NAME
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
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
import com.rtbishop.look4sat.core.data.framework.Ft817Controller import com.rtbishop.look4sat.core.data.framework.Ft817Controller
@@ -149,18 +152,24 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideDatabaseRepo(): IDatabaseRepo { private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher) val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo) return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
} }
private fun provideLocalSource(): ILocalSource { private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400") val builder = Room.databaseBuilder(context, Look4SatDb::class.java, DATABASE_NAME)
val database = builder.fallbackToDestructiveMigration(false).build() val database = builder.addMigrations(MIGRATION_1_2).fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao()) return LocalSource(database.look4SatDao())
} }
private fun provideRemoteSource(): IRemoteSource { private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build()) val version = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
val userAgent = "Look4Sat/$version (+https://github.com/rt-bishop/Look4Sat)"
// Data providers ask clients to identify themselves, so that they can reach out to the
// developer instead of silently blocking every user of the app behind a misbehaving request
val client = OkHttpClient.Builder().addInterceptor { chain ->
chain.proceed(chain.request().newBuilder().header("User-Agent", userAgent).build())
}.build()
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
} }
private fun provideSatelliteRepo(): ISatelliteRepo { private fun provideSatelliteRepo(): ISatelliteRepo {
@@ -175,7 +184,9 @@ class MainContainer(private val context: Context) : IMainContainer {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences" val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE) val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4" val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return SettingsRepo(manager, appPreferences, appVersionName) val appVersionName = packageInfo.versionName ?: "4.0.4"
val appVersionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return SettingsRepo(manager, appPreferences, appVersionName, appVersionCode)
} }
} }
@@ -1,5 +1,7 @@
package com.rtbishop.look4sat.core.data.repository package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatDay import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
@@ -12,6 +14,7 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
@@ -49,6 +52,46 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
SatStatusPage(System.currentTimeMillis(), statuses, reportMap) SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
} }
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
val payload = JSONObject().apply {
put("name", submission.name)
put("report", submission.report)
put("callsign", submission.callsign)
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
}
val response = remoteSource.submitAmSatReport(payload.toString())
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
val (code, body) = response
val errorMessage = parseSubmitError(body)
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
} else {
AmSatReportSubmitResult(
success = false,
message = errorMessage.ifBlank { "HTTP $code" }
)
}
}
private fun parseSubmitError(json: String): String {
return try {
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
} catch (_: Exception) {
""
}
}
private fun parseSubmitReportId(json: String): String? {
return try {
val obj = JSONObject(json)
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
?: obj.optString("id").takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
}
/** Parse catalog JSON to list of satellite names */ /** Parse catalog JSON to list of satellite names */
private fun parseCatalog(json: String): List<String> { private fun parseCatalog(json: String): List<String> {
return try { return try {
@@ -92,11 +135,10 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */ /** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> { private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name } val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
val labels = (0 until 3).map { d -> val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000 utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}" formatDayLabel(utc)
} }
return names.map { name -> return names.map { name ->
val slots = (0 until 36).map { slotIdx -> val slots = (0 until 36).map { slotIdx ->
@@ -121,6 +163,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
} }
} }
private fun formatDayLabel(calendar: Calendar): String {
return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
} else {
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
}
}
private fun toSatReport(r: ApiReport): SatReport { private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000 cal.timeInMillis = r.reportedTimeUtcSec * 1000
@@ -43,8 +43,9 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream)) val entries = mergeEntries(listOf(parseSatelliteStream(uri, unwrapIfZipped(uri, stream))))
localSource.insertEntries(entries) insertFresherEntries(entries)
// report what the file contained: a valid file holding only stale data is not an error
importedCount = entries.size importedCount = entries.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -55,7 +56,7 @@ class DatabaseRepo(
var importedCount = 0 var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream)) val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers) localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size importedCount = transceivers.size
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -65,22 +66,38 @@ class DatabaseRepo(
override suspend fun updateFromRemote() = withContext(dispatcher) { override suspend fun updateFromRemote() = withContext(dispatcher) {
val settings = settingsRepo.dataSourcesSettings.value val settings = settingsRepo.dataSourcesSettings.value
fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url" fun normalizeUrl(url: String) = if (url.startsWith("http")) url else "https://$url"
val tleUrls = settings.satelliteUrls.filter { it.isNotBlank() }.map(::normalizeUrl) val tleUrls = settings.satelliteUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
val radioUrls = settings.transceiversUrls.filter { it.isNotBlank() }.map(::normalizeUrl) val radioUrls = settings.transceiversUrls.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
// launch all network requests concurrently // launch all network requests concurrently, keeping the raw url as key for status reporting
val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.map { url -> async { url to remoteSource.getNetworkStream(normalizeUrl(url)) } }
// parse fetched data concurrently val tleResults = tleJobs.awaitAll()
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) -> val radioResults = radioJobs.awaitAll()
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty() // report the HTTP status code of every source (200, 404, ...)
} settingsRepo.updateDataSourcesStatus(
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) -> (tleResults + radioResults).associate { (url, result) -> url to result.code }
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty() )
// parse fetched data concurrently, merging satellites by freshness instead of source order
val parsedPerSource = tleResults.map { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); parseSatelliteStream(nUrl, unwrapIfZipped(nUrl, it)) }.orEmpty()
} }
val importedRadios = radioResults.flatMap { (url, result) ->
result.stream?.let { val nUrl = normalizeUrl(url); dataParser.parseJSONStream(unwrapIfZipped(nUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database // insert parsed data into the database
localSource.insertEntries(importedEntries) insertFresherEntries(mergeEntries(parsedPerSource))
localSource.insertRadios(importedRadios) // transceivers are a full snapshot: sources publish active entries only, so a retired
setUpdateSuccessful(System.currentTimeMillis()) // transceiver simply disappears from the feed and has to be dropped locally as well.
// Imported ones are kept: no source can refresh them, so nothing would bring them back
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios, isCustom = false)
}
// keep the previous timestamp when every source failed, so the next launch retries
val hasFetchedData = parsedPerSource.any { entries -> entries.isNotEmpty() } || importedRadios.isNotEmpty()
val previousTimestamp = settingsRepo.databaseState.value.updateTimestamp
if (hasFetchedData) pruneStaleEntries()
setUpdateSuccessful(if (hasFetchedData) System.currentTimeMillis() else previousTimestamp)
} }
override suspend fun clearAllData() = withContext(dispatcher) { override suspend fun clearAllData() = withContext(dispatcher) {
@@ -116,6 +133,62 @@ class DatabaseRepo(
line.count { it == ',' } >= 4 line.count { it == ',' } >= 4
} }
/**
* Merges the data of every source: orbital elements always come from the set with the newest
* epoch, while the name comes from the first source that provides it. Source order is thus a
* naming preference only, which also keeps names stable when sources leapfrog each other.
*/
private fun mergeEntries(sourceEntries: List<List<OrbitalData>>): List<OrbitalData> {
val preferredNames = mutableMapOf<Int, String>()
val freshestEntries = mutableMapOf<Int, OrbitalData>()
sourceEntries.forEach { entries ->
entries.forEach { entry ->
val name = entry.name.trim()
if (name.isNotBlank()) preferredNames.getOrPut(entry.catnum) { name }
val current = freshestEntries[entry.catnum]
if (current == null || entry.epochDaynum > current.epochDaynum) {
freshestEntries[entry.catnum] = entry
}
}
}
return freshestEntries.values.map { entry ->
val name = preferredNames[entry.catnum]
if (name == null || name == entry.name) entry else entry.copy(name = name)
}
}
/** Stores the entries that are newer than the ones already saved, renaming the rest in place. */
private suspend fun insertFresherEntries(entries: List<OrbitalData>) {
val storedEpochs = localSource.getEntriesEpochs()
val (fresherEntries, staleEntries) = entries.partition { entry ->
val storedEpoch = storedEpochs[entry.catnum]
storedEpoch == null || entry.epochDaynum > OrbitalData.epochToDaynum(storedEpoch)
}
localSource.insertEntries(fresherEntries)
// a reordered source list has to rename satellites right away, even the ones holding
// elements that are newer than the ones just parsed
if (staleEntries.isNotEmpty()) {
val storedNames = localSource.getEntriesNames()
val renamedEntries = staleEntries.filter { entry -> entry.name != storedNames[entry.catnum] }
if (renamedEntries.isNotEmpty()) {
localSource.renameEntries(renamedEntries.associate { it.catnum to it.name })
}
}
}
/**
* Drops satellites that no enabled source has refreshed for a month: they either decayed or
* disappeared from every catalog. Manually imported data ages out the same way, and every
* source republishes active satellites well within that window.
*/
private suspend fun pruneStaleEntries() {
val currentDaynum = OrbitalData.timeToDaynum(System.currentTimeMillis())
val staleIds = localSource.getEntriesEpochs()
.filterValues { epoch -> currentDaynum - OrbitalData.epochToDaynum(epoch) > 30.0 }
.keys.toList()
if (staleIds.isNotEmpty()) localSource.deleteEntriesWithIds(staleIds)
}
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
settingsRepo.updateDatabaseState( settingsRepo.updateDatabaseState(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.TimeZone import java.util.TimeZone
@@ -67,8 +69,9 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
combine( combine(
settingsRepo.selectedIds, settingsRepo.selectedIds,
settingsRepo.stationPosition settingsRepo.stationPosition,
) { selectedIds, _ -> selectedIds } settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds -> .collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) } _satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value val settings = settingsRepo.passesSettings.value
@@ -176,7 +179,13 @@ class SatelliteRepo(
aosEndMinute: Int, aosEndMinute: Int,
invertAosTimeWindow: Boolean invertAosTimeWindow: Boolean
): Boolean { ): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong() // Must match the timezone used to display pass times (UTC toggle vs device local)
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
TimeZone.getTimeZone("UTC")
} else {
TimeZone.getDefault()
}
val offsetMillis = tz.getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L) val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt() val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) { val inRange = if (aosStartMinute <= aosEndMinute) {
@@ -114,12 +114,29 @@ class SelectionRepo(
/** /**
* Filters items by query. Uses toIntOrNull() instead of exception-based flow, * Filters items by query. Uses toIntOrNull() instead of exception-based flow,
* and lowercases the query once up front instead of per-item. * and lowercases the query once up front instead of per-item.
*
* Fuzzy search: the query is split into space-separated tokens and every
* token must appear in the satellite name after both sides are normalized
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
* previously failed whenever the name contained a separator the query lacked.
*/ */
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> { private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
if (query.isBlank()) return items if (query.isBlank()) return items
val catnum = query.toIntOrNull() val catnum = query.toIntOrNull()
if (catnum != null) return items.filter { it.catnum == catnum } if (catnum != null) return items.filter { it.catnum == catnum }
val lowerQuery = query.lowercase() val tokens = query.split(' ')
return items.filter { it.name.lowercase().contains(lowerQuery) } .map { normalizeForSearch(it) }
.filter { it.isNotEmpty() }
if (tokens.isEmpty()) return items
return items.filter { item ->
val normalizedName = normalizeForSearch(item.name)
tokens.all { normalizedName.contains(it) }
}
} }
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
private fun normalizeForSearch(text: String): String =
text.lowercase().filter { it.isLetterOrDigit() }
} }
@@ -40,11 +40,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.json.JSONObject import org.json.JSONObject
import java.util.Locale
class SettingsRepo( class SettingsRepo(
private val locationManager: LocationManager, private val locationManager: LocationManager,
private val preferences: SharedPreferences, private val preferences: SharedPreferences,
override val appVersionName: String override val appVersionName: String,
override val appVersionCode: Long,
) : ISettingsRepo, LocationListenerCompat { ) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress" private val keyBluetoothRotatorAddress = "bluetoothAddress"
@@ -86,17 +88,33 @@ class SettingsRepo(
private val keyStationTimestamp = "stationTimestamp" private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp" private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew"
private val keySstvMode = "sstvMode" private val keySstvMode = "sstvMode"
private val keyLowElevation = "lowElevation" private val keyLowElevation = "lowElevation"
private val keyHighElevation = "highElevation" private val keyHighElevation = "highElevation"
private val keyRadarCompassOffset = "radarCompassOffset" private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev" private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatelliteUrls = "satelliteUrls" private val keySatellitesUrls = "satellitesUrls"
private val keyTransceiversUrls = "transceiversUrls" private val keyTransceiversUrls = "transceiversUrls"
private val keySatellitesUrlsEnabled = "satellitesUrlsEnabled"
private val keyTransceiversUrlsEnabled = "transceiversUrlsEnabled"
private val keySettingsVersion = "settingsVersion"
private val separatorComma = "," private val separatorComma = ","
private val separatorUrl = "\n" private val separatorUrl = "\n"
init {
// The meaning of the data source order changed, so the stored lists are dropped and the defaults
// are applied again. The update timestamp is cleared as well, to refresh the migrated data
// on the first launch instead of waiting up to 48 hours for the automatic update.
if (preferences.getInt(keySettingsVersion, 0) < appVersionCode) {
preferences.edit {
listOf(keyShouldSeeWhatsNew, keySatellitesUrls, keyTransceiversUrls, keyUpdateTimestamp)
.forEach { key -> remove(key) }
putInt(keySettingsVersion, appVersionCode.toInt())
}
}
}
//region # Satellites selection settings //region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds()) private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes()) private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
@@ -373,21 +391,73 @@ class SettingsRepo(
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun updateDataSourcesSettings(settings: DataSourcesSettings) { override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
// Normalize the enabled lists so they are positionally aligned with the URL lists.
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
// invariant intact even when a default empty list is used to construct the model.
val normalized = settings.copy(
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
)
preferences.edit { preferences.edit {
putString(keySatelliteUrls, settings.satelliteUrls.joinToString(separatorUrl)) putString(keySatellitesUrls, normalized.satelliteUrls.joinToString(separatorUrl))
putString(keyTransceiversUrls, settings.transceiversUrls.joinToString(separatorUrl)) putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
putString(keySatellitesUrlsEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
putString(keyTransceiversUrlsEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
} }
_dataSourcesSettings.value = settings _dataSourcesSettings.value = normalized
} }
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings( private fun getDataSourcesSettings(): DataSourcesSettings {
satelliteUrls = preferences.getString(keySatelliteUrls, null) val (satUrls, satEnabled) = parseSources(
?.split(separatorUrl)?.filter { it.isNotBlank() } preferences.getString(keySatellitesUrls, null),
?: Sources.satelliteDataUrls, preferences.getString(keySatellitesUrlsEnabled, null),
transceiversUrls = preferences.getString(keyTransceiversUrls, null) Sources.satelliteDataUrls
?.split(separatorUrl)?.filter { it.isNotBlank() } )
?: Sources.transceiversDataUrls val (txUrls, txEnabled) = parseSources(
) preferences.getString(keyTransceiversUrls, null),
preferences.getString(keyTransceiversUrlsEnabled, null),
Sources.transceiversDataUrls
)
return DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
}
private fun parseSources(
storedUrls: String?,
storedEnabled: String?,
defaults: List<String>
): Pair<List<String>, List<Boolean>> {
if (storedUrls == null) return defaults to defaults.map { true }
val urls = storedUrls.split(separatorUrl)
val flags = if (storedEnabled.isNullOrEmpty()) emptyList() else storedEnabled.split(separatorComma)
val filteredUrls = mutableListOf<String>()
val filteredFlags = mutableListOf<Boolean>()
urls.forEachIndexed { index, url ->
if (url.isNotBlank()) {
filteredUrls.add(url)
filteredFlags.add(flags.getOrNull(index)?.toBoolean() ?: true)
}
}
return filteredUrls to filteredFlags
}
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
if (flags.size >= urls.size) return flags.take(urls.size)
return flags + List(urls.size - flags.size) { true }
}
//endregion
//region # Data sources status
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
override fun updateDataSourcesStatus(status: Map<String, Int>) {
_dataSourcesStatus.value = status
}
//endregion //endregion
//region # Radio control settings //region # Radio control settings
@@ -450,4 +520,16 @@ class SettingsRepo(
} }
preferences.edit { putString(keySatelliteOffsets, updated) } preferences.edit { putString(keySatelliteOffsets, updated) }
} }
//region # AMSAT status report settings
private val keyAmSatCallsign = "amSatCallsign"
override fun getAmSatCallsign(): String {
return preferences.getString(keyAmSatCallsign, "").orEmpty()
}
override fun setAmSatCallsign(callsign: String) {
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
}
//endregion
} }
@@ -43,6 +43,16 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return selectedOrbitalObjects return selectedOrbitalObjects
} }
override suspend fun getEntriesEpochs() = look4SatDao.getEntriesEpochs()
override suspend fun getEntriesNames() = look4SatDao.getEntriesNames()
override suspend fun renameEntries(names: Map<Int, String>) =
names.forEach { (catnum, name) -> look4SatDao.renameEntry(catnum, name) }
override suspend fun deleteEntriesWithIds(ids: List<Int>) =
ids.chunked(999).forEach { idsPart -> look4SatDao.deleteEntriesWithIds(idsPart) }
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity()) override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries() override suspend fun deleteEntries() = look4SatDao.deleteEntries()
@@ -51,12 +61,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData( private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun OrbitalData.toEntity() = FrameworkEntry( private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl, this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
) )
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() } private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,15 +83,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios() return look4SatDao.getRadiosWithId(id).toDomainRadios()
} }
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.toFrameworkRadios()) look4SatDao.insertRadios(radios.map { radio -> radio.toFramework(isCustom) })
} }
override suspend fun deleteRadios() = look4SatDao.deleteRadios() override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio( override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
private fun DomainRadio.toFramework(isCustom: Boolean = false) = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode, this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, isCustom
) )
private fun FrameworkRadio.toDomain() = DomainRadio( private fun FrameworkRadio.toDomain() = DomainRadio(
@@ -89,8 +101,6 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
) )
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() } private fun List<FrameworkRadio>.toDomainRadios() = this.map { radio -> radio.toDomain() }
//endregion //endregion
@@ -20,10 +20,13 @@ package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver import android.content.ContentResolver
import androidx.core.net.toUri import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream import java.io.InputStream
class RemoteSource( class RemoteSource(
@@ -42,20 +45,26 @@ class RemoteSource(
} }
} }
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) { override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
try { try {
val networkRequest = Request.Builder().url(url).build() val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute() val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val code = response.code
response.close() response.close()
return@withContext null return@withContext NetworkResult(code, null)
} }
// Return the body stream directly as the caller is responsible for closing it // Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool // That returns the connection to OkHttp's pool
response.body.byteStream().buffered() val body = response.body
if (body == null) {
response.close()
return@withContext NetworkResult(response.code, null)
}
NetworkResult(response.code, body.byteStream().buffered())
} catch (exception: Exception) { } catch (exception: Exception) {
println("RemoteSource network stream exception: $exception") println("RemoteSource network stream exception: $exception")
null NetworkResult(NetworkResult.CONNECTION_ERROR, null)
} }
} }
@@ -90,4 +99,21 @@ class RemoteSource(
null null
} }
} }
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
try {
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("https://www.amsat.org/status/api/v1/reports.php")
.header("User-Agent", "Look4Sat")
.post(body)
.build()
httpClient.newCall(request).execute().use { response ->
response.code to response.body.string()
}
} catch (exception: Exception) {
println("RemoteSource submitAmSatReport exception: $exception")
null
}
}
} }
@@ -0,0 +1,33 @@
package com.rtbishop.look4sat.core.data.framework
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class IcomCivProtocolTest {
private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() }
@Test
fun parseFreqModePayload_ic705ReadFreqReply() {
// Reply captured from an IC-705 to CMD 0x03: frequency only, no mode byte
val reply = bytes(0xFE, 0xFE, 0xE0, 0xA4, 0x03, 0x60, 0x74, 0x95, 0x45, 0x01, 0xFD)
val response = IcomCivProtocol.parseResponse(reply, IcomCivProtocol.CMD_READ_FREQ)
assertNotNull(response)
assertEquals(145957460L to "", IcomCivProtocol.parseFreqModePayload(response!!.payload))
}
@Test
fun parseFreqModePayload_withModeByte() {
assertEquals(
435611000L to "USB",
IcomCivProtocol.parseFreqModePayload(bytes(0x00, 0x10, 0x61, 0x35, 0x04, 0x01, 0x01))
)
}
@Test
fun parseFreqModePayload_tooShort() {
assertNull(IcomCivProtocol.parseFreqModePayload(bytes(0x60, 0x74, 0x95, 0x45)))
}
}
@@ -31,6 +31,7 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.utility.DataParser import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -41,6 +42,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest { class DatabaseRepoTest {
@@ -48,6 +50,10 @@ class DatabaseRepoTest {
private val dispatcher = StandardTestDispatcher() private val dispatcher = StandardTestDispatcher()
private val dataParser = DataParser(dispatcher) private val dataParser = DataParser(dispatcher)
// fixtures carry a current epoch: stale entries are pruned on every remote update
private val todayEpoch = LocalDate.now().let { "%02d%03d".format(it.year % 100, it.dayOfYear) }
private val fresherEpoch = "$todayEpoch.71955234".toDouble()
@Test @Test
fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) { fun `manual satellite import parses csv stream from content uri`() = runTest(dispatcher) {
val uri = "content://look4sat/import/satellites" val uri = "content://look4sat/import/satellites"
@@ -101,34 +107,198 @@ class DatabaseRepoTest {
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 }) assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
} }
@Test
fun `remote update takes the freshest elements and the preferred name`() = runTest(dispatcher) {
val primaryUrl = "https://example.com/primary.txt"
val secondaryUrl = "https://example.com/secondary.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[primaryUrl] = { validTleStream() }
networkStreams[secondaryUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(primaryUrl, secondaryUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single { it.catnum == 25544 }
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `manual satellite import does not overwrite fresher data`() = runTest(dispatcher) {
val freshUri = "content://look4sat/import/fresh"
val staleUri = "content://look4sat/import/stale"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[freshUri] = { fresherTleStream() }
fileStreams[staleUri] = { validTleStream() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
assertEquals(1, repository.updateTLEFromFile(freshUri))
assertEquals(1, repository.updateTLEFromFile(staleUri))
assertEquals(fresherEpoch, localSource.insertedEntries.single().epoch, 1e-8)
}
@Test
fun `remote update drops transceivers that are no longer published`() = runTest(dispatcher) {
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
remoteSource.networkStreams[radioUrl] = { twoRadiosStream() }
repository.updateFromRemote()
assertEquals(2, localSource.insertedRadios.size)
remoteSource.networkStreams[radioUrl] = { singleRadioStream() }
repository.updateFromRemote()
assertEquals(listOf("uuid-alive"), localSource.insertedRadios.map { it.uuid })
}
@Test
fun `remote update renames satellites even when their elements are not newer`() = runTest(dispatcher) {
val preferredUrl = "https://example.com/preferred.txt"
val freshUrl = "https://example.com/fresh.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[freshUrl] = { fresherTleStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(freshUrl),
transceiversUrls = emptyList()
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
assertEquals("ISS", localSource.insertedEntries.single().name)
// the user moves a source with older elements, but a better name, to the top of the list
remoteSource.networkStreams[preferredUrl] = { validTleStream() }
settingsRepo.dataSourcesSettings.value = DataSourcesSettings(
satelliteUrls = listOf(preferredUrl, freshUrl),
transceiversUrls = emptyList()
)
repository.updateFromRemote()
val entry = localSource.insertedEntries.single()
assertEquals("ISS (ZARYA)", entry.name)
assertEquals(fresherEpoch, entry.epoch, 1e-8)
}
@Test
fun `failed remote update records the attempt without claiming success`() = runTest(dispatcher) {
val failingUrl = "https://example.com/offline.txt"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource()
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = listOf(failingUrl),
transceiversUrls = emptyList()
)
)
settingsRepo.databaseState.value = DatabaseState(0, 0, 1_000L)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateFromRemote()
// the update timestamp stays put so the data is not presented as fresh, while the recorded
// attempt keeps the next app launch from hammering a source that is already failing
assertEquals(1_000L, settingsRepo.databaseState.value.updateTimestamp)
}
@Test
fun `remote update keeps the transceivers imported from a file`() = runTest(dispatcher) {
val radioUri = "content://imported.json"
val radioUrl = "https://example.com/transmitters.json"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[radioUri] = { customRadioStream() }
networkStreams[radioUrl] = { singleRadioStream() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
satelliteUrls = emptyList(),
transceiversUrls = listOf(radioUrl)
)
)
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
repository.updateTransceiversFromFile(radioUri)
repository.updateFromRemote()
// the remote snapshot replaces what the sources publish, the imported entry stays put
assertEquals(setOf("uuid-custom", "uuid-alive"), localSource.insertedRadios.map { it.uuid }.toSet())
}
private fun validCsvStream(): InputStream = """ private fun validCsvStream(): InputStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0 ISS (ZARYA),1998-067A,${LocalDate.now()}T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun validTleStream(): InputStream = """ private fun validTleStream(): InputStream = """
ISS (ZARYA) ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990 1 25544U 98067A $todayEpoch.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205 2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream() """.trimIndent().byteInputStream()
private fun fresherTleStream(): InputStream = """
ISS
1 25544U 98067A $todayEpoch.71955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private fun twoRadiosStream(): InputStream = """
[$radioAlive,{"uuid":"uuid-retired","description":"Retired","alive":true,"downlink_low":437800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
private fun singleRadioStream(): InputStream = "[$radioAlive]".byteInputStream()
private fun customRadioStream(): InputStream = """
[{"uuid":"uuid-custom","description":"Local beacon","alive":true,"downlink_low":144800000,
"mode":"FM","invert":false,"norad_cat_id":25544}]
""".trimIndent().byteInputStream()
} }
private const val radioAlive = """{"uuid":"uuid-alive","description":"Voice repeater","alive":true,
"downlink_low":145800000,"mode":"FM","invert":false,"norad_cat_id":25544}"""
private class FakeRemoteSource : IRemoteSource { private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf() val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke() override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke() override suspend fun getNetworkStream(url: String): NetworkResult {
val stream = networkStreams[url]?.invoke()
return if (stream != null) NetworkResult(200, stream) else NetworkResult(404, null)
}
override suspend fun getAmSatCatalog(): String? = null override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
} }
private class FakeLocalSource : ILocalSource { private class FakeLocalSource : ILocalSource {
val insertedEntries = mutableListOf<OrbitalData>() val insertedEntries = mutableListOf<OrbitalData>()
private val insertedRadios = mutableListOf<SatRadio>() val insertedRadios = mutableListOf<SatRadio>()
private val customRadios = mutableListOf<String>()
override suspend fun getEntriesTotal(): Int = insertedEntries.size override suspend fun getEntriesTotal(): Int = insertedEntries.size
@@ -136,6 +306,21 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> =
insertedEntries.associate { entry -> entry.catnum to entry.epoch }
override suspend fun getEntriesNames(): Map<Int, String> =
insertedEntries.associate { entry -> entry.catnum to entry.name }
override suspend fun renameEntries(names: Map<Int, String>) = names.forEach { (catnum, name) ->
val index = insertedEntries.indexOfFirst { entry -> entry.catnum == catnum }
if (index >= 0) insertedEntries[index] = insertedEntries[index].copy(name = name)
}
override suspend fun deleteEntriesWithIds(ids: List<Int>) {
insertedEntries.removeAll { entry -> entry.catnum in ids }
}
override suspend fun insertEntries(entries: List<OrbitalData>) { override suspend fun insertEntries(entries: List<OrbitalData>) {
insertedEntries += entries insertedEntries += entries
} }
@@ -150,18 +335,27 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) { override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios.removeAll { stored -> radios.any { radio -> radio.uuid == stored.uuid } }
insertedRadios += radios insertedRadios += radios
if (isCustom) customRadios += radios.map { radio -> radio.uuid }
else customRadios -= radios.map { radio -> radio.uuid }.toSet()
} }
override suspend fun deleteRadios() { override suspend fun deleteRadios() {
insertedRadios.clear() insertedRadios.clear()
customRadios.clear()
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { radio -> radio.uuid !in customRadios }
} }
} }
private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo { private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSourcesSettings()) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -185,6 +379,8 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources) override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
override val dataSourcesStatus: MutableStateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
@@ -214,11 +410,19 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
dataSourcesSettings.value = settings dataSourcesSettings.value = settings
} }
override fun updateDataSourcesStatus(status: Map<String, Int>) {
dataSourcesStatus.value = status
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
} }
private fun defaultDataSourcesSettings(): DataSourcesSettings { private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -84,6 +84,65 @@ class SelectionRepoTest {
assertEquals(listOf(true, false, false), items.map { it.isSelected }) assertEquals(listOf(true, false, false), items.map { it.isSelected })
} }
@Test
fun `query without separators matches name with dashes spaces brackets`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("ao7")
val items = flow.first()
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The key
// guarantee is that AO-7 — unreachable before because of dashes/brackets
// — is now found.
assertEquals(7530, items.first().catnum)
assertEquals(listOf(7530, 39444), items.map { it.catnum })
}
@Test
fun `query without separators matches name with only dashes`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("fo29")
assertEquals(listOf(99999), flow.first().map { it.catnum })
}
@Test
fun `space-separated tokens all must match in any order`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zarya iss")
assertEquals(listOf(25544), flow.first().map { it.catnum })
}
@Test
fun `partial token query matches substring`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("funcube")
assertEquals(listOf(39444), flow.first().map { it.catnum })
}
@Test
fun `unmatched query returns nothing`() = runTest(dispatcher) {
val repository = repoWithSearchEntries()
val flow = repository.getEntriesFlow()
repository.setQuery("zzzznomatch")
assertEquals(emptyList<Int>(), flow.first().map { it.catnum })
}
private fun repoWithSearchEntries(): SelectionRepo {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(7530, "AO-7 (AMSAT-OSCAR 7)", false),
SatItem(39444, "AO-73 (FUNcube-1)", false),
SatItem(43803, "JO-97 (BIRDS-3)", false),
SatItem(99999, "FO-29", false)
)
)
return SelectionRepo(dispatcher, localSource, FakeSettingsRepo(selectedModes = emptyList()))
}
private class FakeLocalSource( private class FakeLocalSource(
private val entries: List<SatItem> private val entries: List<SatItem>
) : ILocalSource { ) : ILocalSource {
@@ -93,6 +152,14 @@ class SelectionRepoTest {
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList() override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun getEntriesEpochs(): Map<Int, Double> = emptyMap()
override suspend fun getEntriesNames(): Map<Int, String> = emptyMap()
override suspend fun renameEntries(names: Map<Int, String>) = Unit
override suspend fun deleteEntriesWithIds(ids: List<Int>) = Unit
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit override suspend fun deleteEntries() = Unit
@@ -103,9 +170,11 @@ class SelectionRepoTest {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList() override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
override suspend fun deleteRadios() = Unit override suspend fun deleteRadios() = Unit
override suspend fun deleteManagedRadios() = Unit
} }
private class FakeSettingsRepo( private class FakeSettingsRepo(
@@ -113,6 +182,7 @@ class SelectionRepoTest {
) : ISettingsRepo { ) : ISettingsRepo {
override val appVersionName: String = "test" override val appVersionName: String = "test"
override val appVersionCode: Long = 1L
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList()) override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
@@ -138,6 +208,8 @@ class SelectionRepoTest {
DataSourcesSettings(emptyList(), emptyList()) DataSourcesSettings(emptyList(), emptyList())
) )
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow( override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600) RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
) )
@@ -166,12 +238,16 @@ class SelectionRepoTest {
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = "" override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
override fun getAmSatCallsign(): String = ""
override fun setAmSatCallsign(callsign: String) = Unit
} }
} }
@@ -0,0 +1,17 @@
package com.rtbishop.look4sat.core.domain.model
/** One public AMSAT satellite status report submission. */
data class AmSatReportSubmission(
val name: String,
val report: String,
val callsign: String,
val gridSquare: String,
val reportedAtUtcMillis: Long
)
/** Result of submitting an AMSAT satellite status report. */
data class AmSatReportSubmitResult(
val success: Boolean,
val message: String = "",
val reportId: String? = null
)
@@ -20,17 +20,19 @@ package com.rtbishop.look4sat.core.domain.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** Only the uuid is mandatory: third party sources rarely publish the full SatNOGS schema, and a
* missing optional field should not cost the whole transceiver. */
@Serializable @Serializable
data class SatRadio( data class SatRadio(
@SerialName("uuid") val uuid: String, @SerialName("uuid") val uuid: String,
@SerialName("description") val info: String, @SerialName("description") val info: String = "",
@SerialName("alive") val isAlive: Boolean, @SerialName("alive") val isAlive: Boolean = true,
@SerialName("downlink_low") val downlinkLow: Long?, @SerialName("downlink_low") val downlinkLow: Long? = null,
@SerialName("downlink_high") val downlinkHigh: Long?, @SerialName("downlink_high") val downlinkHigh: Long? = null,
@SerialName("mode") val downlinkMode: String?, @SerialName("mode") val downlinkMode: String? = null,
@SerialName("uplink_low") val uplinkLow: Long?, @SerialName("uplink_low") val uplinkLow: Long? = null,
@SerialName("uplink_high") val uplinkHigh: Long?, @SerialName("uplink_high") val uplinkHigh: Long? = null,
@SerialName("uplink_mode") val uplinkMode: String?, @SerialName("uplink_mode") val uplinkMode: String? = null,
@SerialName("invert") val isInverted: Boolean, @SerialName("invert") val isInverted: Boolean = false,
@SerialName("norad_cat_id") val catnum: Int? @SerialName("norad_cat_id") val catnum: Int? = null
) )
@@ -67,10 +67,21 @@ data class OtherSettings(
val radarCompassOffsetElev: Float = 0f val radarCompassOffsetElev: Float = 0f
) )
/**
* Data source URLs (TLE / transceivers) and their per-source enabled flags.
* [satelliteEnabled] and [transceiversEnabled] must be positionally aligned with the
* corresponding URL list: the flag at index [i] applies to the URL at index [i].
* Missing flags are treated as enabled (see [isSatelliteEnabled]/[isTransceiverEnabled]).
*/
data class DataSourcesSettings( data class DataSourcesSettings(
val satelliteUrls: List<String>, val satelliteUrls: List<String>,
val transceiversUrls: List<String> val transceiversUrls: List<String>,
) val satelliteEnabled: List<Boolean> = emptyList(),
val transceiversEnabled: List<Boolean> = emptyList()
) {
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
}
data class RadioControlSettings( data class RadioControlSettings(
val enabled: Boolean, val enabled: Boolean,
@@ -424,8 +424,12 @@ object CelestialComputer {
} }
if (sunrise == 0.0) sunrise = daynum if (sunrise == 0.0) sunrise = daynum
// Phase 4: fast-forward through the day until sun drops back below threshold // Phase 4: fast-forward through the day until sun drops back below threshold.
daynum = sunrise // Start from just after sunrise (small offset) so the sun is clearly above
// the threshold. This prevents a bug where Phase 3 converges to a point
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
// to the same time as sunrise, producing identical sunrise/sunset times.
daynum = sunrise + 0.001
sunPos = getSunPosition(observer, daynumToMillis(daynum)) sunPos = getSunPosition(observer, daynumToMillis(daynum))
guard = 0 guard = 0
while (sunPos.elevation > -threshold && guard++ < 500) { while (sunPos.elevation > -threshold && guard++ < 500) {
@@ -37,30 +37,42 @@ data class OrbitalData(
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min) val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
/** Absolute age of this element set, used to pick the freshest data among several sources. */
val epochDaynum: Double = epochToDaynum(epoch)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this) fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
/** Check if satellite has likely decayed by the given time. */ /** Check if satellite has likely decayed by the given time. */
fun hasDecayed(currentTimeMillis: Long): Boolean { fun hasDecayed(currentTimeMillis: Long): Boolean {
if (ndot == 0.0) return false if (ndot == 0.0) return false
val currentDaynum = (currentTimeMillis - 315446400000L) / 86400000.0 return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, timeToDaynum(currentTimeMillis))
val epochDaynum = epochToDaynum(epoch)
return CelestialComputer.hasDecayed(meanmo, ndot, epochDaynum, currentDaynum)
} }
private fun epochToDaynum(epoch: Double): Double { companion object {
var year = kotlin.math.floor(epoch * 1E-3) /** Days since 31 Dec 1979, the reference point every daynum in the app is based on. */
val day = (epoch * 1E-3 - year) * 1000.0 fun timeToDaynum(timeMillis: Long): Double = (timeMillis - 315446400000L) / 86400000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double { /**
val aYear = theYear - 1 * Days since 31 Dec 1979 for a TLE style YYDDD.ffffffff epoch. Raw epochs are not
val a = kotlin.math.floor(aYear / 100).toLong() * comparable across decades (a 1999 epoch reads as 99xxx, a 2026 one as 26xxx), so
val b = 2 - a + a / 4 * freshness checks must always go through this conversion.
val i = kotlin.math.floor(365.25 * aYear).toLong() */
return i + (30.6001 * 14).toLong() + 1720994.5 + b fun epochToDaynum(epoch: Double): Double {
var year = kotlin.math.floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
// daynum = days since 31 Dec 1979, Julian date of 31Dec79 = 2444238.5
val jan1Jd = julianDateOfYear(year)
return jan1Jd + day - 2444238.5
}
private fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
val a = kotlin.math.floor(aYear / 100).toLong()
val b = 2 - a + a / 4
val i = kotlin.math.floor(365.25 * aYear).toLong()
return i + (30.6001 * 14).toLong() + 1720994.5 + b
}
} }
} }
@@ -1,9 +1,14 @@
package com.rtbishop.look4sat.core.domain.repository package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
import com.rtbishop.look4sat.core.domain.model.SatStatusPage import com.rtbishop.look4sat.core.domain.model.SatStatusPage
/** AMSAT satellite status data source */ /** AMSAT satellite status data source */
interface IAmSatRepository { interface IAmSatRepository {
/** Fetch and parse the AMSAT status page; null on failure */ /** Fetch and parse the AMSAT status page; null on failure */
suspend fun fetchStatus(): SatStatusPage? suspend fun fetchStatus(): SatStatusPage?
/** Submit a public AMSAT satellite status report. */
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
} }
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo { interface ISettingsRepo {
val appVersionName: String val appVersionName: String
val appVersionCode: Long
//region # Satellites selection settings //region # Satellites selection settings
val selectedIds: StateFlow<List<Int>> val selectedIds: StateFlow<List<Int>>
@@ -71,6 +72,11 @@ interface ISettingsRepo {
fun updateDataSourcesSettings(settings: DataSourcesSettings) fun updateDataSourcesSettings(settings: DataSourcesSettings)
//endregion //endregion
//region # Data sources status
val dataSourcesStatus: StateFlow<Map<String, Int>>
fun updateDataSourcesStatus(status: Map<String, Int>)
//endregion
//region # Radio control settings //region # Radio control settings
val radioControlSettings: StateFlow<RadioControlSettings> val radioControlSettings: StateFlow<RadioControlSettings>
fun updateRadioControlSettings(settings: RadioControlSettings) fun updateRadioControlSettings(settings: RadioControlSettings)
@@ -80,4 +86,9 @@ interface ISettingsRepo {
fun getSatelliteOffset(catnum: Int): String fun getSatelliteOffset(catnum: Int): String
fun setSatelliteOffset(catnum: Int, offset: String) fun setSatelliteOffset(catnum: Int, offset: String)
//endregion //endregion
//region # AMSAT status report settings
fun getAmSatCallsign(): String
fun setAmSatCallsign(callsign: String)
//endregion
} }
@@ -26,11 +26,18 @@ interface ILocalSource {
suspend fun getEntriesTotal(): Int suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem> suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun getEntriesEpochs(): Map<Int, Double>
suspend fun getEntriesNames(): Map<Int, String>
suspend fun renameEntries(names: Map<Int, String>)
suspend fun deleteEntriesWithIds(ids: List<Int>)
suspend fun insertEntries(entries: List<OrbitalData>) suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries() suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int> suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>) suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean)
suspend fun deleteRadios() suspend fun deleteRadios()
/** Drops the transceivers provided by the remote sources, keeping the imported ones. */
suspend fun deleteManagedRadios()
} }
@@ -19,9 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream import java.io.InputStream
/** Result of a network download: the HTTP status code and the response body stream. */
data class NetworkResult(val code: Int, val stream: InputStream?) {
companion object {
/** Code used when a request fails before any HTTP response is received. */
const val CONNECTION_ERROR = -1
}
}
interface IRemoteSource { interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream? suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream? suspend fun getNetworkStream(url: String): NetworkResult
suspend fun getAmSatCatalog(): String? suspend fun getAmSatCatalog(): String?
suspend fun getAmSatReports(hours: Int, limit: Int): String? suspend fun getAmSatReports(hours: Int, limit: Int): String?
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
} }
@@ -18,17 +18,19 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
// Ordered from the most specific to the most generic source: the first source that knows a
// satellite gets to name it, with the full catalog acting as the fallback.
val satelliteDataUrls = listOf( val satelliteDataUrls = listOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "live.ariss.org/iss.txt",
"db.satnogs.org/api/tle/?format=3le",
"amsat.org/tle/current/nasabare.txt",
"mmccants.org/tles/classfd.zip",
"r4uab.ru/satonline.txt", "r4uab.ru/satonline.txt",
"live.ariss.org/iss.txt" "mmccants.org/tles/classfd.zip",
"amsat.org/tle/current/nasabare.txt",
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"db.satnogs.org/api/tle/?format=3le"
) )
val transceiversDataUrls = listOf( val transceiversDataUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active", "r4uab.ru/transmitters.json",
"r4uab.ru/transmitters.json" "db.satnogs.org/api/transmitters/?format=json&status=active"
) )
val satelliteModes = listOf( val satelliteModes = listOf(
"4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK", "4FSK", "64-QAM", "AFSK", "AFSK TUBiX10", "AHRPT", "AM", "APT", "ASK", "BPSK",
@@ -33,10 +33,19 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
ignoreUnknownKeys = true ignoreUnknownKeys = true
coerceInputValues = true coerceInputValues = true
} }
private val alpha5Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ"
private val celestrakCSVColumns = mapOf(
"OBJECT_NAME" to 0, "EPOCH" to 2, "MEAN_MOTION" to 3, "ECCENTRICITY" to 4,
"INCLINATION" to 5, "RA_OF_ASC_NODE" to 6, "ARG_OF_PERICENTER" to 7,
"MEAN_ANOMALY" to 8, "NORAD_CAT_ID" to 11, "BSTAR" to 14, "MEAN_MOTION_DOT" to 15
)
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) { suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines -> stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList() val iterator = lines.iterator()
if (!iterator.hasNext()) return@useLines emptyList()
val columns = parseCSVColumns(iterator.next())
iterator.asSequence().mapNotNull { line -> parseCSV(line.split(","), columns) }.toList()
} }
} }
@@ -58,35 +67,63 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
private fun parseCSV(values: List<String>): OrbitalData? = runCatching { private fun parseCSV(values: List<String>, columns: Map<String, Int>): OrbitalData? = runCatching {
val name = values[0] fun value(column: String) = values[columns.getValue(column)].trim()
val timestamp = values[2] fun optionalValue(column: String) =
val year = timestamp.substring(0, 4) columns[column]?.let { index -> values.getOrNull(index) }?.trim()?.toDoubleOrNull() ?: 0.0
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val dayInt = getDayOfYear(year.toInt(), month, dayOfMonth)
val day = dayInt.toString().padStart(3, '0')
val hour = timestamp.substring(11, 13).toInt() * 3600000
val min = timestamp.substring(14, 16).toInt() * 60000
val sec = timestamp.substring(17, 19).toInt() * 1000
val ms = timestamp.substring(20, 26).toInt() / 1000.0
val frac = ((hour + min + sec + ms) / 86400000.0).toString().substring(1)
val epoch = "${year.substring(2)}$day$frac".toDouble()
OrbitalData( OrbitalData(
name = name, name = value("OBJECT_NAME"),
epoch = epoch, epoch = parseTimestamp(value("EPOCH")),
meanmo = values[3].toDouble(), meanmo = value("MEAN_MOTION").toDouble(),
eccn = values[4].toDouble(), eccn = value("ECCENTRICITY").toDouble(),
incl = values[5].toDouble(), incl = value("INCLINATION").toDouble(),
raan = values[6].toDouble(), raan = value("RA_OF_ASC_NODE").toDouble(),
argper = values[7].toDouble(), argper = value("ARG_OF_PERICENTER").toDouble(),
meanan = values[8].toDouble(), meanan = value("MEAN_ANOMALY").toDouble(),
catnum = values[11].toInt(), catnum = parseCatnum(value("NORAD_CAT_ID")),
bstar = values[14].toDouble(), bstar = optionalValue("BSTAR"),
ndot = values[15].toDouble() ndot = optionalValue("MEAN_MOTION_DOT")
) )
}.onFailure { println("CSV parsing exception: $it") }.getOrNull() }.onFailure { println("CSV parsing exception: $it") }.getOrNull()
/**
* OMM columns are located by name, as providers agree on the names but not on the order.
* Falls back to the Celestrak layout when the header is missing or unrecognized.
*/
private fun parseCSVColumns(header: String): Map<String, Int> {
val columns = header.split(",").withIndex().associate { (index, name) ->
name.trim().trim('"').uppercase() to index
}
return if (columns.containsKey("NORAD_CAT_ID")) columns else celestrakCSVColumns
}
/**
* ISO 8601 timestamp as a TLE style YYDDD.ffffffff epoch. Fractional seconds are optional and
* a trailing timezone marker is ignored, as not every provider formats the epoch alike.
*/
private fun parseTimestamp(timestamp: String): Double {
val year = timestamp.substring(0, 4).toInt()
val month = timestamp.substring(5, 7).toInt()
val dayOfMonth = timestamp.substring(8, 10).toInt()
val hours = timestamp.substring(11, 13).toInt()
val minutes = timestamp.substring(14, 16).toInt()
val seconds = timestamp.substring(17).takeWhile { it.isDigit() || it == '.' }.toDouble()
val dayFraction = (hours * 3600 + minutes * 60 + seconds) / 86400.0
return (year % 100) * 1000 + getDayOfYear(year, month, dayOfMonth) + dayFraction
}
/**
* Catalog numbers above 99999 do not fit the 5 digit TLE field, so they are encoded as Alpha-5:
* the leading two digits become a letter, with I and O skipped to avoid confusion with 1 and 0.
*/
private fun parseCatnum(value: String): Int {
val catnum = value.trim()
if (catnum.first().isDigit()) return catnum.toInt()
val alphaIndex = alpha5Alphabet.indexOf(catnum.first().uppercaseChar())
require(alphaIndex >= 0) { "Unknown Alpha-5 catalog number: $catnum" }
return (alphaIndex + 10) * 10000 + catnum.drop(1).trim().toInt()
}
private fun parseTLE(tle: List<String>): OrbitalData? = runCatching { private fun parseTLE(tle: List<String>): OrbitalData? = runCatching {
val line1 = tle[1] val line1 = tle[1]
val line2 = tle[2] val line2 = tle[2]
@@ -99,7 +136,7 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
raan = line2.substring(17, 25).toDouble(), raan = line2.substring(17, 25).toDouble(),
argper = line2.substring(34, 42).toDouble(), argper = line2.substring(34, 42).toDouble(),
meanan = line2.substring(43, 51).toDouble(), meanan = line2.substring(43, 51).toDouble(),
catnum = line1.substring(2, 7).trim().toInt(), catnum = parseCatnum(line1.substring(2, 7)),
bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()), bstar = 1e-5 * line1.substring(53, 59).toDouble() / 10.0.pow(line1.substring(60, 61).toDouble()),
ndot = line1.substring(33, 43).trim().toDouble() ndot = line1.substring(33, 43).trim().toDouble()
) )
@@ -23,6 +23,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.math.abs
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class DataParserTest { class DataParserTest {
@@ -61,8 +62,49 @@ class DataParserTest {
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) { fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream) val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2) assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234) assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-8)
assert(parsedList[1].epoch == 24069.23963816) assert(abs(parsedList[1].epoch - 24069.23963816) < 1e-8)
}
@Test
fun `Given CSV stream with reordered columns returns valid data`() = runTest(testDispatcher) {
val csvStream = """
NORAD_CAT_ID,EPOCH,OBJECT_NAME,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY
25544,2021-11-16T12:28:09.322176,ISS (ZARYA),.31985E-4,.1288E-4,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.bstar == 0.31985E-4)
assert(abs(sat.epoch - 21320.51955234) < 1e-8)
}
@Test
fun `Given CSV epoch without fractional seconds returns valid data`() = runTest(testDispatcher) {
val csvStream = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09Z,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2021-11-16T00:00:30,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val parsedList = dataParser.parseCSVStream(csvStream)
assert(parsedList.size == 2)
// the dropped microseconds are worth a few microdays, the rest of the epoch is intact
assert(abs(parsedList[0].epoch - 21320.51955234) < 1e-5)
// just past midnight: the old formatter produced a broken 21320.0E-4 style epoch here
assert(abs(parsedList[1].epoch - 21320.00034722) < 1e-8)
}
@Test
fun `Given TLE stream with alpha-5 catalog number returns valid data`() = runTest(testDispatcher) {
val tleStream = """
NAVSTAR 43
1 T0111U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 T0111 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
// A stands for 10 and I and O are skipped, so T is 27 and T0111 stands for 270111
assert(sat.catnum == 270111)
} }
@Test @Test
@@ -0,0 +1,79 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -52,7 +52,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -305,6 +305,7 @@ fun InfoDialog(
title: String, title: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAccept: () -> Unit, onAccept: () -> Unit,
extraAction: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
DialogShell(onDismissRequest = onDismiss) { padding -> DialogShell(onDismissRequest = onDismiss) { padding ->
@@ -323,6 +324,10 @@ fun InfoDialog(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
extraAction?.let {
it()
Spacer(modifier = Modifier.width(8.dp))
}
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept)) CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
} }
content() content()
@@ -425,11 +430,11 @@ private fun DialogShell(
@Composable @Composable
fun hasEnoughHeight(): Boolean = fun hasEnoughHeight(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480) currentWindowAdaptiveInfoV2().windowSizeClass.isHeightAtLeastBreakpoint(480)
@Composable @Composable
fun hasEnoughWidth(): Boolean = fun hasEnoughWidth(): Boolean =
currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600) currentWindowAdaptiveInfoV2().windowSizeClass.isWidthAtLeastBreakpoint(600)
@Composable @Composable
fun isVerticalLayout(): Boolean = !hasEnoughWidth() fun isVerticalLayout(): Boolean = !hasEnoughWidth()
@@ -0,0 +1,272 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.draw.innerShadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
//region DragDrop Region
@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
offset.y.toInt() in item.offset..(item.offset + item.size)
}?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f, spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd && draggingItem.index != item.index
}
if (targetItem != null) {
if (draggingItem.index == state.firstVisibleItemIndex || targetItem.index == state.firstVisibleItemIndex) {
state.requestScrollToItem(state.firstVisibleItemIndex, state.firstVisibleItemScrollOffset)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 -> (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 -> (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val zIndexMod = Modifier.zIndex(1f)
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = when {
dragging -> zIndexMod.graphicsLayer { translationY = dragDropState.draggingItemOffset }
index == dragDropState.previousIndexOfDraggedItem -> {
zIndexMod.graphicsLayer { translationY = dragDropState.previousItemOffset.value }
}
else -> Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}
//endregion
//region Swipe Region
@Composable
fun SwipeableItem(onRemove: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val isVisible = remember { mutableStateOf(true) }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
hapticFeedback.performHapticFeedback(feedbackType)
}
AnimatedVisibility(visible = isVisible.value, exit = fadeOut(spring())) {
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = false,
backgroundContent = { DismissBackground(willTrigger) },
content = { content() },
onDismiss = {
isVisible.value = false
coroutineScope.launch { dismissState.reset() }
}
)
}
LaunchedEffect(isVisible.value) {
if (!isVisible.value) { delay(250.milliseconds).also { onRemove() } }
}
}
@Composable
fun DismissBackground(willTrigger: Boolean, bgColor: Color = Color(0xFFFF3C3A)) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxSize()
.border(width = 2.dp, shape = MaterialTheme.shapes.extraLarge, color = bgColor)
.dropShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = MaterialTheme.shapes.extraLarge) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
//endregion
@@ -0,0 +1,311 @@
/*
* 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 androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
/**
* Long-press drag-to-reorder for rows inside a `LazyColumn`, with built-in edge auto-scroll and
* a settle-back animation when the finger is released.
*
* Reordering only ever swaps an item with its neighbor *inside the `items` list* passed to
* [rememberDragRowState] — so multiple independent groups of rows (e.g. two sections separated
* by header items) can share one [DragReorderState]/`LazyColumn` simply by giving each section
* its own backing list and calling [rememberDragRowState] with that section's list. Keys must be
* globally unique across the whole `LazyColumn` and match exactly what is passed as the
* `key = { ... }` lambda of `items`/`itemsIndexed`.
*
* Usage:
* ```
* val dragState = rememberDragReorderState(listState)
* itemsIndexed(entries, key = { _, e -> e.id }) { index, entry ->
* val row = rememberDragRowState(dragState, entries, entry, key = { it.id }) { from, to ->
* entries.add(to, entries.removeAt(from))
* }
* Row(
* modifier = Modifier
* .dragLift(row.isLifted, row.translationY)
* .animateItem(placementSpec = if (row.isDragging) tween(0) else spring())
* ) {
* Icon(modifier = Modifier.dragHandle(row), painter = ..., contentDescription = null)
* Text(entry.label)
* }
* }
* ```
*/
@Stable
class DragReorderState internal constructor(internal val listState: LazyListState) {
internal var draggedKey by mutableStateOf<Any?>(null)
}
@Composable
fun rememberDragReorderState(listState: LazyListState): DragReorderState =
remember(listState) { DragReorderState(listState) }
/** Used for the settle-back animation once the finger is released: quick and snappy. */
private val reorderSettleSpec = tween<Float>(durationMillis = 150, easing = FastOutSlowInEasing)
/**
* Per-row drag bookkeeping, obtained via [rememberDragRowState].
*
* [dragStartOffset] is this row's viewport offset captured once at drag start, [fingerOffset] is
* the raw accumulated finger travel (used for edge auto-scroll and swap detection). [translationY]
* is derived every frame from those two plus the row's *current* live layout offset, so it
* automatically stays correct across neighbor swaps and list auto-scroll without any manual
* offset-compensation math.
*/
@Stable
class DragRowState<T> internal constructor(
private val dragState: DragReorderState,
private val itemsState: State<List<T>>,
private val itemKey: Any,
private val key: (T) -> Any,
private val onMoveState: State<(Int, Int) -> Unit>,
private val scope: CoroutineScope
) {
internal val fingerOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val dragStartOffset: MutableFloatState = mutableFloatStateOf(0f)
internal val startCenterY: MutableFloatState = mutableFloatStateOf(0f)
internal val settleAnim = Animatable(0f)
var isSettling: Boolean by mutableStateOf(false)
private set
val isDragging: Boolean get() = dragState.draggedKey == itemKey
val isLifted: Boolean get() = isDragging || isSettling
val translationY: Float
get() = when {
isSettling -> settleAnim.value
isDragging -> currentTranslation()
else -> 0f
}
/**
* How far this row must be pushed away from its *current* layout slot so it stays glued to
* the finger. Because [dragStartOffset] is fixed at drag start while the row's live layout
* offset moves as neighbors swap places or the list auto-scrolls, this difference naturally
* absorbs both effects with no extra bookkeeping.
*/
private fun currentTranslation(): Float {
val liveOffset = dragState.listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == itemKey }?.offset?.toFloat()
?: dragStartOffset.floatValue
return dragStartOffset.floatValue + fingerOffset.floatValue - liveOffset
}
internal fun onDragStart() {
isSettling = false
val layout = dragState.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == itemKey }
dragStartOffset.floatValue = (layout?.offset ?: 0).toFloat()
startCenterY.floatValue = (layout?.offset ?: 0) + (layout?.size ?: 0) / 2f
fingerOffset.floatValue = 0f
dragState.draggedKey = itemKey
}
internal fun onDrag(deltaY: Float) {
if (dragState.draggedKey != itemKey) return
fingerOffset.floatValue += deltaY
reorderLive()
}
private fun reorderLive() {
val items = itemsState.value
val myIndex = items.indexOfFirst { key(it) == itemKey }
if (myIndex !in items.indices) return
val myCenter = startCenterY.floatValue + fingerOffset.floatValue
val visible = dragState.listState.layoutInfo.visibleItemsInfo
val onMove = onMoveState.value
// Dragging down: swap when the dragged center passes the next row's midpoint.
if (myIndex < items.lastIndex) {
val nextKey = key(items[myIndex + 1])
val next = visible.firstOrNull { it.key == nextKey }
if (next != null && myCenter > next.offset + next.size / 2f) {
onMove(myIndex, myIndex + 1)
return
}
}
// Dragging up: swap when the dragged center passes the previous row's midpoint.
if (myIndex > 0) {
val prevKey = key(items[myIndex - 1])
val prev = visible.firstOrNull { it.key == prevKey }
if (prev != null && myCenter < prev.offset + prev.size / 2f) {
onMove(myIndex, myIndex - 1)
}
}
}
// Reset the drag bookkeeping and fly the lifted row back into its slot.
// All state resets happen inside the launched block so the settle animation takes over from
// the current visual position without a one-frame jump: isSettling is flipped to true
// (switching rendering to settleAnim, already snapped to the last offset) before the drag
// flags are cleared.
internal fun onDragEnd() {
val lastOffset = currentTranslation()
if (kotlin.math.abs(lastOffset) < 1f) {
fingerOffset.floatValue = 0f
dragState.draggedKey = null
return
}
scope.launch {
settleAnim.snapTo(lastOffset)
isSettling = true
fingerOffset.floatValue = 0f
dragState.draggedKey = null
settleAnim.animateTo(0f, reorderSettleSpec)
isSettling = false
}
}
}
/**
* Remembers a [DragRowState] for one row of a drag-reorderable list.
*
* [items] must be the exact (optionally section-scoped) list backing the enclosing
* `items`/`itemsIndexed` call, and [key] must return the same value used as that call's
* `key = { ... }` lambda. Reordering only ever swaps neighbors within [items], so passing a
* section-local list is what confines dragging to one section of a multi-section `LazyColumn`.
*/
@Composable
fun <T> rememberDragRowState(
dragState: DragReorderState,
items: List<T>,
item: T,
key: (T) -> Any,
onMove: (from: Int, to: Int) -> Unit
): DragRowState<T> {
val scope = rememberCoroutineScope()
val itemsState = rememberUpdatedState(items)
val onMoveState = rememberUpdatedState(onMove)
val itemKey = key(item)
val rowState = remember(dragState, itemKey) {
DragRowState(dragState, itemsState, itemKey, key, onMoveState, scope)
}
LaunchedEffect(rowState.isDragging) {
if (!rowState.isDragging) return@LaunchedEffect
autoScrollWhileDragging(dragState.listState, rowState.startCenterY, rowState.fingerOffset)
}
return rowState
}
/** Drag-handle gesture: attach to a small handle icon to start/drive/end reordering of [rowState]'s row. */
fun Modifier.dragHandle(rowState: DragRowState<*>): Modifier = pointerInput(rowState) {
detectDragGesturesAfterLongPress(
onDragStart = { rowState.onDragStart() },
onDragEnd = { rowState.onDragEnd() },
onDragCancel = { rowState.onDragEnd() }
) { change, dragAmount ->
change.consume()
rowState.onDrag(dragAmount.y)
}
}
/**
* Visual treatment for a draggable row: while [isLifted], translates the row by [translationY],
* scales it up slightly and raises it above its neighbors with a shadow and a solid background so
* it fully covers the row beneath instead of showing a translucent overlap of both rows.
*/
@Composable
fun Modifier.dragLift(isLifted: Boolean, translationY: Float): Modifier {
val shape = MaterialTheme.shapes.small
val scale by animateFloatAsState(
targetValue = if (isLifted) 1.02f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "dragScale"
)
return this
.graphicsLayer {
if (isLifted) {
this.translationY = translationY
scaleX = scale
scaleY = scale
}
}
.then(
if (isLifted) {
Modifier
.zIndex(1f)
.shadow(8.dp, shape, clip = false)
.background(MaterialTheme.colorScheme.surface, shape)
} else {
Modifier
}
)
}
/**
* Scrolls the list while dragging so the entry follows the finger past the viewport edges.
* The visual center is tracked independently of the entry's layout slot (which can scroll out of
* `LazyListState.layoutInfo.visibleItemsInfo` during a long drag); [startCenterY] is the entry's
* viewport center captured at drag start and [fingerOffset] is the raw finger delta. The
* resulting scroll is picked up automatically by [DragRowState.translationY] on the next frame,
* so no separate scroll-compensation bookkeeping is required here.
*/
private suspend fun autoScrollWhileDragging(
listState: LazyListState,
startCenterY: MutableFloatState,
fingerOffset: MutableFloatState
) {
val threshold = 48f
val maxSpeed = 24f
while (true) {
val info = listState.layoutInfo
val center = startCenterY.floatValue + fingerOffset.floatValue
val top = info.viewportStartOffset + threshold
val bottom = info.viewportEndOffset - threshold
val delta = when {
center < top -> -(top - center).coerceAtMost(maxSpeed)
center > bottom -> (center - bottom).coerceAtMost(maxSpeed)
else -> 0f
}
if (delta != 0f) listState.scrollBy(delta)
delay(16L.milliseconds)
}
}
@@ -46,7 +46,7 @@ data object Spacing {
val extraSmall = 6.dp val extraSmall = 6.dp
val small = 8.dp val small = 8.dp
val medium = 12.dp val medium = 12.dp
val large = 16.dp val large = 12.dp
val extraLarge = 24.dp val extraLarge = 24.dp
} }
@@ -61,8 +61,8 @@ fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable
SideEffect { SideEffect {
val window = (view.context as ComponentActivity).window val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = false insetsController.isAppearanceLightNavigationBars = !isDarkTheme
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false window.isNavigationBarContrastEnforced = false
} }
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4V15z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,840L240,760Q240,760 256.5,750Q273,740 292.5,720.5Q312,701 328,670.5Q344,640 344,600Q344,589 342.5,579Q341,569 339,560L240,560L240,480L300,480Q279,447 259.5,410.5Q240,374 240,320Q240,228 304,164Q368,100 460,100Q531,100 586,139Q641,178 665,240L591,271Q576,231 540.5,205.5Q505,180 460,180Q402,180 361,221Q320,262 320,320Q320,368 344,400Q368,432 393,480L560,480L560,560L421,560Q423,569 423.5,579Q424,589 424,600Q424,650 406.5,690Q389,730 364,760L560,760Q600,760 621,739Q642,718 650,685L720,720Q709,775 663.5,807.5Q618,840 560,840L240,840Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>
@@ -118,6 +118,11 @@
<string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string> <string name="prefs_data_import_transceivers_error">No se importaron transceptores. Usa un SatNOGS (.json) válido.</string>
<string name="prefs_data_sources_title">Fuentes personalizadas</string> <string name="prefs_data_sources_title">Fuentes personalizadas</string>
<string name="prefs_data_sources_restore">Restaurar fuentes predeterminadas</string>
<string name="prefs_data_sources_sat_title">Datos de satélites</string>
<string name="prefs_data_sources_transceivers_title">Datos de transceptores</string>
<string name="prefs_data_sources_sat_hint">Prioridad arriba: nombres de satélites</string>
<string name="prefs_data_sources_transceivers_hint">Prioridad arriba: datos de transceptores</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL transceptores</string> <string name="prefs_data_sources_transceivers_switch">URL transceptores</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -156,10 +161,13 @@
<string name="prefs_other_title">Otros</string> <string name="prefs_other_title">Otros</string>
<string name="prefs_other_switch_utc">Hora UTC</string> <string name="prefs_other_switch_utc">Hora UTC</string>
<string name="prefs_other_switch_update">Actualización automática</string> <string name="prefs_other_switch_update">Auto-actualizar</string>
<string name="prefs_other_switch_sweep">Animación radar</string> <string name="prefs_other_switch_sweep">Animación radar</string>
<string name="prefs_other_switch_sensors">Sensores</string> <string name="prefs_other_switch_sensors">Sensores</string>
<string name="prefs_other_switch_night_mode">Filtro nocturno</string> <string name="prefs_other_switch_night_mode">Filtro nocturno</string>
<string name="prefs_other_switch_light_theme">Tema claro</string>
<string name="prefs_other_compass_offset_az">Desfase de brújula (ácimut)</string>
<string name="prefs_other_compass_offset_elev">Desfase de brújula (elevación)</string>
<string name="prefs_highlight_title">Resaltado de elevación</string> <string name="prefs_highlight_title">Resaltado de elevación</string>
<string name="prefs_highlight_low">Baja &lt; %1$d°</string> <string name="prefs_highlight_low">Baja &lt; %1$d°</string>
@@ -117,6 +117,11 @@
<string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string> <string name="prefs_data_import_transceivers_error">Трансиверы не импортированы. Нужен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Свои источники</string> <string name="prefs_data_sources_title">Свои источники</string>
<string name="prefs_data_sources_restore">Восстановить источники по умолчанию</string>
<string name="prefs_data_sources_sat_title">Данные спутников</string>
<string name="prefs_data_sources_transceivers_title">Данные трансиверов</string>
<string name="prefs_data_sources_sat_hint">Приоритет сверху: названия спутников</string>
<string name="prefs_data_sources_transceivers_hint">Приоритет сверху: данные трансиверов</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверов</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверов</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -156,10 +161,13 @@
<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>
<string name="prefs_other_switch_update">Автообновление данных</string> <string name="prefs_other_switch_update">Автообновление</string>
<string name="prefs_other_switch_sweep">Анимация радара</string> <string name="prefs_other_switch_sweep">Анимация радара</string>
<string name="prefs_other_switch_sensors">Сенсоры</string> <string name="prefs_other_switch_sensors">Сенсоры</string>
<string name="prefs_other_switch_night_mode">Ночной фильтр</string> <string name="prefs_other_switch_night_mode">Ночной фильтр</string>
<string name="prefs_other_switch_light_theme">Светлая тема</string>
<string name="prefs_other_compass_offset_az">Смещение компаса (азимут)</string>
<string name="prefs_other_compass_offset_elev">Смещение компаса (элевация)</string>
<string name="prefs_highlight_title">Подсветка высоты</string> <string name="prefs_highlight_title">Подсветка высоты</string>
<string name="prefs_highlight_low">Низко &lt; %1$d°</string> <string name="prefs_highlight_low">Низко &lt; %1$d°</string>
@@ -118,6 +118,11 @@
<string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string> <string name="prefs_data_import_transceivers_error">සම්ප්‍රේෂක ආයාත නොවීය. වලංගු SatNOGS (.json) ගොනුවක් තෝරන්න.</string>
<string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string> <string name="prefs_data_sources_title">අභිරුචි මූලාශ්‍ර</string>
<string name="prefs_data_sources_restore">පෙරනිමි මූලාශ්‍ර ප්‍රතිස්ථාපනය කරන්න</string>
<string name="prefs_data_sources_sat_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_sources_transceivers_title">සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_sat_hint">ඉහළ ප්‍රමුඛතාව: චන්ද්‍රිකා නම්</string>
<string name="prefs_data_sources_transceivers_hint">ඉහළ ප්‍රමුඛතාව: සම්ප්‍රේෂක දත්ත</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceivers URL</string> <string name="prefs_data_sources_transceivers_switch">Transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -156,10 +161,13 @@
<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>
<string name="prefs_other_switch_update">දත්ත ස්වයං යාවත්කාලීන</string> <string name="prefs_other_switch_update">ස්වයං යාවත්කාලීන</string>
<string name="prefs_other_switch_sweep">Radar sweep</string> <string name="prefs_other_switch_sweep">රේඩාර් පරිලෝකනය</string>
<string name="prefs_other_switch_sensors">සංවේදක</string> <string name="prefs_other_switch_sensors">සංවේදක</string>
<string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string> <string name="prefs_other_switch_night_mode">රාත්‍රී පෙරහන</string>
<string name="prefs_other_switch_light_theme">ආලෝක තේමාව</string>
<string name="prefs_other_compass_offset_az">මාලිමා අපගමනය (දිගංශය)</string>
<string name="prefs_other_compass_offset_elev">මාලිමා අපගමනය (උත්තෝලනය)</string>
<string name="prefs_highlight_title">උස් උද්දීපනය</string> <string name="prefs_highlight_title">උස් උද්දීපනය</string>
<string name="prefs_highlight_low">අඩු &lt; %1$d°</string> <string name="prefs_highlight_low">අඩු &lt; %1$d°</string>
@@ -149,6 +149,11 @@
<string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string> <string name="prefs_data_import_transceivers_error">Transceiver içe aktarılmadı. Geçerli bir SatNOGS (.json) dosyası seçin.</string>
<string name="prefs_data_sources_title">Özel kaynaklar</string> <string name="prefs_data_sources_title">Özel kaynaklar</string>
<string name="prefs_data_sources_restore">Varsayılan kaynakları geri yükle</string>
<string name="prefs_data_sources_sat_title">Uydu verileri</string>
<string name="prefs_data_sources_transceivers_title">Transceiver verileri</string>
<string name="prefs_data_sources_sat_hint">Öncelik üstte: uydu adları</string>
<string name="prefs_data_sources_transceivers_hint">Öncelik üstte: transceiver verileri</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Transceiver URL</string> <string name="prefs_data_sources_transceivers_switch">Transceiver URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -188,10 +193,13 @@
<string name="prefs_other_title">Diğer</string> <string name="prefs_other_title">Diğer</string>
<string name="prefs_other_switch_utc">UTC saati</string> <string name="prefs_other_switch_utc">UTC saati</string>
<string name="prefs_other_switch_update">Veriyi otomatik güncelle</string> <string name="prefs_other_switch_update">Otomatik güncelle</string>
<string name="prefs_other_switch_sweep">Radar taraması</string> <string name="prefs_other_switch_sweep">Radar taraması</string>
<string name="prefs_other_switch_sensors">Sensör</string> <string name="prefs_other_switch_sensors">Sensör</string>
<string name="prefs_other_switch_night_mode">Gece filtresi</string> <string name="prefs_other_switch_night_mode">Gece filtresi</string>
<string name="prefs_other_switch_light_theme">Açık tema</string>
<string name="prefs_other_compass_offset_az">Pusula sapması (azimut)</string>
<string name="prefs_other_compass_offset_elev">Pusula sapması (yükseklik açısı)</string>
<string name="prefs_highlight_title">Elevasyon vurgusu</string> <string name="prefs_highlight_title">Elevasyon vurgusu</string>
<string name="prefs_highlight_low">Düşük &lt; %1$d°</string> <string name="prefs_highlight_low">Düşük &lt; %1$d°</string>
@@ -118,6 +118,11 @@
<string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string> <string name="prefs_data_import_transceivers_error">Трансивери не імпортовано. Потрібен SatNOGS (.json).</string>
<string name="prefs_data_sources_title">Власні джерела</string> <string name="prefs_data_sources_title">Власні джерела</string>
<string name="prefs_data_sources_restore">Відновити джерела за замовчуванням</string>
<string name="prefs_data_sources_sat_title">Дані супутників</string>
<string name="prefs_data_sources_transceivers_title">Дані трансиверів</string>
<string name="prefs_data_sources_sat_hint">Пріоритет зверху: назви супутників</string>
<string name="prefs_data_sources_transceivers_hint">Пріоритет зверху: дані трансиверів</string>
<string name="prefs_data_sources_tle_switch">URL TLE</string> <string name="prefs_data_sources_tle_switch">URL TLE</string>
<string name="prefs_data_sources_transceivers_switch">URL трансиверів</string> <string name="prefs_data_sources_transceivers_switch">URL трансиверів</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -156,10 +161,13 @@
<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>
<string name="prefs_other_switch_update">Автооновлення даних</string> <string name="prefs_other_switch_update">Автооновлення</string>
<string name="prefs_other_switch_sweep">Анімація радара</string> <string name="prefs_other_switch_sweep">Анімація радара</string>
<string name="prefs_other_switch_sensors">Сенсори</string> <string name="prefs_other_switch_sensors">Сенсори</string>
<string name="prefs_other_switch_night_mode">Нічний фільтр</string> <string name="prefs_other_switch_night_mode">Нічний фільтр</string>
<string name="prefs_other_switch_light_theme">Світла тема</string>
<string name="prefs_other_compass_offset_az">Зміщення компаса (азимут)</string>
<string name="prefs_other_compass_offset_elev">Зміщення компаса (піднесення)</string>
<string name="prefs_highlight_title">Підсвічування висоти</string> <string name="prefs_highlight_title">Підсвічування висоти</string>
<string name="prefs_highlight_low">Низько &lt; %1$d°</string> <string name="prefs_highlight_low">Низько &lt; %1$d°</string>
@@ -12,6 +12,45 @@
<string name="nav_map">地图</string> <string name="nav_map">地图</string>
<string name="nav_prefs">设置</string> <string name="nav_prefs">设置</string>
<!-- AMSAT Status screen -->
<string name="nav_amsat">AMSAT</string>
<string name="amsat_title">AMSAT 卫星状态</string>
<string name="amsat_refresh">刷新</string>
<string name="amsat_retry">重试</string>
<string name="amsat_updated">更新于:</string>
<string name="amsat_active">活跃</string>
<string name="amsat_tlm">遥测/信标</string>
<string name="amsat_not_heard">未听到</string>
<string name="amsat_conflict">冲突</string>
<string name="amsat_name">名称</string>
<string name="amsat_load_failed">加载失败 - 检查网络后重试</string>
<string name="amsat_no_reports">此时间段无报告</string>
<string name="amsat_close">关闭</string>
<string name="amsat_status_heard">已听到</string>
<string name="amsat_status_tlm_only">仅遥测</string>
<string name="amsat_status_not_heard">未听到</string>
<string name="amsat_status_crew_active">乘组活跃</string>
<string name="amsat_upload">上传</string>
<string name="amsat_upload_back">返回</string>
<string name="amsat_upload_report">上传报告到 AMSAT</string>
<string name="amsat_upload_status">状态</string>
<string name="amsat_upload_callsign">呼号</string>
<string name="amsat_upload_grid">网格</string>
<string name="amsat_upload_time_now">时间:当前 UTC %s</string>
<string name="amsat_upload_submit">提交到 AMSAT</string>
<string name="amsat_upload_confirm">确认上传</string>
<string name="amsat_upload_confirm_title">确认上传到 AMSAT</string>
<string name="amsat_upload_confirm_message">这条公开报告将上传到 AMSAT。\n卫星:%1$s\n状态:%2$s\n呼号:%3$s\n网格:%4$s</string>
<string name="amsat_upload_grid_none">未填写</string>
<string name="amsat_upload_success">报告已上传</string>
<string name="amsat_upload_failed">上传失败:%s</string>
<string name="amsat_upload_callsign_required">请填写呼号</string>
<string name="amsat_upload_grid_invalid">网格应为 4 或 6 位 Maidenhead 定位符</string>
<string name="amsat_upload_active">活跃</string>
<string name="amsat_upload_tlm">遥测/信标</string>
<string name="amsat_upload_not_heard">未听到</string>
<string name="amsat_upload_crew_active">ISS 语音</string>
<!-- Satellites screen --> <!-- Satellites screen -->
<string name="sat_type_hint">模式:%s</string> <string name="sat_type_hint">模式:%s</string>
<string name="sat_type_title">选择模式</string> <string name="sat_type_title">选择模式</string>
@@ -64,6 +103,24 @@
<string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string> <string name="radar_doppler_rx_hint">输入下行频率 (MHz)</string>
<string name="radar_doppler_offset_hint">偏移 (kHz)</string> <string name="radar_doppler_offset_hint">偏移 (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">连接</string>
<string name="radar_disconnect">断开连接</string>
<string name="radar_off">关</string>
<string name="radar_stop">停止</string>
<string name="radar_track">跟踪</string>
<string name="radar_tx_base">TX 基频:</string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">授予权限</string>
<string name="sstv_listening">正在监听…</string>
<string name="sstv_mic_permission">需要麦克风权限</string>
<string name="sstv_mode_format">模式:%s</string>
<string name="sstv_mode_title">SSTV 模式</string>
<string name="sstv_no_signal">无信号</string>
<string name="sstv_no_transceiver">未选择收发器</string>
<string name="sstv_rx_format">RX:%s Hz</string>
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">上一个</string> <string name="map_prev">上一个</string>
<string name="map_next">下一个</string> <string name="map_next">下一个</string>
@@ -84,6 +141,12 @@
<string name="prefs_donate_title">支持</string> <string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string> <string name="prefs_privacy_title">隐私政策</string>
<string name="settings_paired_devices">已配对的蓝牙设备:</string>
<string name="settings_split_mode">分频模式(仅 IC-705)</string>
<string name="prefs_lat_prefix">纬度:%s°</string>
<string name="prefs_lon_prefix">经度:%s°</string>
<string name="prefs_qth_prefix">QTH:%s</string>
<string name="prefs_updated_title">更新:%s</string> <string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string> <string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
@@ -113,6 +176,11 @@
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string> <string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
<string name="prefs_data_sources_title">自定义源</string> <string name="prefs_data_sources_title">自定义源</string>
<string name="prefs_data_sources_restore">恢复默认源</string>
<string name="prefs_data_sources_sat_title">卫星数据</string>
<string name="prefs_data_sources_transceivers_title">收发器数据</string>
<string name="prefs_data_sources_sat_hint">靠前优先:卫星名称</string>
<string name="prefs_data_sources_transceivers_hint">靠前优先:收发器数据</string>
<string name="prefs_data_sources_tle_switch">TLE URL</string> <string name="prefs_data_sources_tle_switch">TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">收发器 URL</string> <string name="prefs_data_sources_transceivers_switch">收发器 URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string> <string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
@@ -151,12 +219,13 @@
<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>
<string name="prefs_other_switch_update">自动更新数据</string> <string name="prefs_other_switch_update">自动更新</string>
<string name="prefs_other_switch_sweep">雷达扫描动画</string> <string name="prefs_other_switch_sweep">雷达扫描</string>
<string name="prefs_other_switch_sensors">传感器控制雷达</string> <string name="prefs_other_switch_sensors">传感器</string>
<string name="prefs_other_switch_night_mode">红色夜间模式</string> <string name="prefs_other_switch_night_mode">红色夜间模式</string>
<string name="prefs_other_compass_offset">雷达罗盘水平偏移量</string> <string name="prefs_other_switch_light_theme">浅色主题</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘俯仰偏移量</string> <string name="prefs_other_compass_offset_az">罗盘偏移量(方位角)</string>
<string name="prefs_other_compass_offset_elev">罗盘偏移量(仰角)</string>
<string name="prefs_highlight_title">仰角高亮</string> <string name="prefs_highlight_title">仰角高亮</string>
<string name="prefs_highlight_low">低 &lt; %1$d°</string> <string name="prefs_highlight_low">低 &lt; %1$d°</string>
@@ -173,4 +242,7 @@
\n• Libre Space Foundation (SatNOGS)</string> \n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
<!-- Main screen -->
<string name="tracking_format">跟踪:%s</string>
</resources> </resources>
@@ -56,13 +56,12 @@
\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</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added AMSAT status tracking page by MCKero6423 (#234) * Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
\n* Tweaked Radar track on reposition by atsunatsu (#235) \n\n* Applied elevation color thresholds to the Radar by atsunatsu (#253)
\n* Tweaked linear transponder calc by atsunatsu (#236) \n\n* Added fuzzy satellite search ignoring separators by atsunatsu (#255)
\n* Added saving per-sat doppler offset by atsunatsu (#237) \n\n* Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
\n* Added custom freq offset setting to network reporting \n\n* Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
\n* Added ability to customize data sources via import \n\n* Fixed day/night map overlay calculation and display issue by rt-bishop
\n* Fixed star chart in README by PingouinFerreux (#240)
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -79,6 +78,30 @@
<string name="amsat_load_failed">Load failed - check network and retry</string> <string name="amsat_load_failed">Load failed - check network and retry</string>
<string name="amsat_no_reports">No reports for this period</string> <string name="amsat_no_reports">No reports for this period</string>
<string name="amsat_close">Close</string> <string name="amsat_close">Close</string>
<string name="amsat_status_heard">Heard</string>
<string name="amsat_status_tlm_only">Telemetry Only</string>
<string name="amsat_status_not_heard">Not Heard</string>
<string name="amsat_status_crew_active">Crew Active</string>
<string name="amsat_upload">Upload</string>
<string name="amsat_upload_back">Back</string>
<string name="amsat_upload_report">Upload report to AMSAT</string>
<string name="amsat_upload_status">Status</string>
<string name="amsat_upload_callsign">Callsign</string>
<string name="amsat_upload_grid">Grid</string>
<string name="amsat_upload_time_now">Time: current UTC %s</string>
<string name="amsat_upload_submit">Submit to AMSAT</string>
<string name="amsat_upload_confirm">Upload</string>
<string name="amsat_upload_confirm_title">Confirm AMSAT upload</string>
<string name="amsat_upload_confirm_message">This public report will be uploaded to AMSAT.\nSatellite: %1$s\nStatus: %2$s\nCallsign: %3$s\nGrid: %4$s</string>
<string name="amsat_upload_grid_none">Not provided</string>
<string name="amsat_upload_success">Report uploaded</string>
<string name="amsat_upload_failed">Upload failed: %s</string>
<string name="amsat_upload_callsign_required">Callsign is required</string>
<string name="amsat_upload_grid_invalid">Grid must be a 4 or 6 character Maidenhead locator</string>
<string name="amsat_upload_active">Active</string>
<string name="amsat_upload_tlm">TLM/Beacon</string>
<string name="amsat_upload_not_heard">Not Heard</string>
<string name="amsat_upload_crew_active">Crew Active</string>
<!-- Radar screen --> <!-- Radar screen -->
<string name="radar_back">Back</string> <string name="radar_back">Back</string>
@@ -109,6 +132,24 @@
<string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string> <string name="radar_doppler_rx_hint">Enter RX freq (MHz)</string>
<string name="radar_doppler_offset_hint">Offset (kHz)</string> <string name="radar_doppler_offset_hint">Offset (kHz)</string>
<!-- Radar screen (CAT control / SSTV) -->
<string name="radar_connect">Connect</string>
<string name="radar_disconnect">Disconnect</string>
<string name="radar_off">Off</string>
<string name="radar_stop">Stop</string>
<string name="radar_track">Track</string>
<string name="radar_tx_base">TX Base: </string>
<!-- SSTV screen -->
<string name="sstv_grant_permission">Grant permission</string>
<string name="sstv_listening">Listening…</string>
<string name="sstv_mic_permission">Microphone access needed</string>
<string name="sstv_mode_format">Mode: %s</string>
<string name="sstv_mode_title">SSTV Mode</string>
<string name="sstv_no_signal">No signal</string>
<string name="sstv_no_transceiver">No transceiver selected</string>
<string name="sstv_rx_format">RX: %s Hz</string>
<!-- 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>
@@ -141,6 +182,12 @@
<string name="prefs_privacy_title">Privacy Policy</string> <string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string> <string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="settings_paired_devices">Paired Bluetooth Devices:</string>
<string name="settings_split_mode">Split mode (IC-705 only)</string>
<string name="prefs_lat_prefix">Lat: %s°</string>
<string name="prefs_lon_prefix">Lon: %s°</string>
<string name="prefs_qth_prefix">Qth: %s</string>
<string name="prefs_updated_title">Updated: %s</string> <string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string> <string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
@@ -170,6 +217,11 @@
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string> <string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
<string name="prefs_data_sources_title">Data sources</string> <string name="prefs_data_sources_title">Data sources</string>
<string name="prefs_data_sources_restore">Restore default sources</string>
<string name="prefs_data_sources_sat_title">Satellites data</string>
<string name="prefs_data_sources_transceivers_title">Transceivers data</string>
<string name="prefs_data_sources_sat_hint">Top priority affects satellite names</string>
<string name="prefs_data_sources_transceivers_hint">Top priority affects transceivers data</string>
<string name="prefs_data_output_title">Data output</string> <string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string> <string name="prefs_net_output">Network</string>
@@ -208,12 +260,13 @@
<string name="prefs_other_title">Other</string> <string name="prefs_other_title">Other</string>
<string name="prefs_other_switch_utc">UTC time</string> <string name="prefs_other_switch_utc">UTC time</string>
<string name="prefs_other_switch_update">Auto-update data</string> <string name="prefs_other_switch_update">Auto-update</string>
<string name="prefs_other_switch_sweep">Radar sweep</string> <string name="prefs_other_switch_sweep">Radar sweep</string>
<string name="prefs_other_switch_sensors">Use sensors</string> <string name="prefs_other_switch_sensors">Sensors</string>
<string name="prefs_other_switch_night_mode">Night filter</string> <string name="prefs_other_switch_night_mode">Night filter</string>
<string name="prefs_other_compass_offset">Radar compass offset</string> <string name="prefs_other_switch_light_theme">Light theme</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string> <string name="prefs_other_compass_offset_az">Compass offset (azimuth)</string>
<string name="prefs_other_compass_offset_elev">Compass offset (elevation)</string>
<string name="prefs_highlight_title">Elevation highlight</string> <string name="prefs_highlight_title">Elevation highlight</string>
<string name="prefs_highlight_low">Low &lt; %1$d°</string> <string name="prefs_highlight_low">Low &lt; %1$d°</string>
@@ -232,4 +285,7 @@
</string> </string>
<string name="prefs_outro_license">The app comes with no warranty</string> <string name="prefs_outro_license">The app comes with no warranty</string>
<!-- Main screen -->
<string name="tracking_format">Tracking: %s</string>
</resources> </resources>
@@ -1,7 +1,6 @@
* Added AMSAT status tracking page by MCKero6423 (#234) * Fixed AOS window filter to follow the UTC setting by wty2019wty (#252)
* Tweaked Radar track on reposition by atsunatsu (#235) * Applied elevation color thresholds to the Radar by atsunatsu (#253)
* Tweaked linear transponder calc by atsunatsu (#236) * Added fuzzy satellite search ignoring separators by atsunatsu (#255)
* Added saving per-sat doppler offset by atsunatsu (#237) * Added light theme suitable for e-ink devices by Ondrej Kolonicny (#256)
* Added custom freq offset setting to network reporting * Fixed IC-705 frequency read in dual-radio mode by Michael S (#260)
* Added ability to customize data sources via import * Fixed day/night map overlay calculation and display issue by rt-bishop
* Fixed star chart in README by PingouinFerreux (#240)
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos import kotlin.math.cos
@@ -39,9 +40,11 @@ import kotlin.math.sin
* exceeds 90°, i.e. the dot product of the two unit vectors is negative: * 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 * 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 * Performance: we sample one column per stepPx pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig * filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible. * evaluations per row, which is imperceptible. draw() is called on every
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/ */
class MapNightOverlay : Overlay() { class MapNightOverlay : Overlay() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
private val rect = RectF() private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// We scan column by column. For each column we determine the longitude, // We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it. // then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night // Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0 var x = 0
while (x < w) { while (x < w) {
// Get the geographic coordinate at the top and bottom of this column. val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
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 lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad) 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(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when { when {
dotTop < 0 && dotBot < 0 -> { dotTop < 0 && dotBot < 0 -> {
@@ -109,8 +116,9 @@ class MapNightOverlay : Overlay() {
else -> { else -> {
// Terminator crosses this column — find the crossing pixel by binary search // Terminator crosses this column — find the crossing pixel by binary search
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff) val isNightAtTop = dotTop < 0
if (dotTop < 0) { val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff, isNightAtTop)
if (isNightAtTop) {
// Night at top, day at bottom // Night at top, day at bottom
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat()) rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
canvas.drawRect(rect, nightPaint) canvas.drawRect(rect, nightPaint)
@@ -127,7 +135,11 @@ class MapNightOverlay : Overlay() {
/** /**
* Binary-search for the pixel row where the day/night boundary crosses column [x]. * 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). *
* [isNightAtTop] states which side [yTop] is on. Both polarities occur: the pole nearest
* the sub-solar point is lit, so the terminator runs day-over-night while the sub-solar
* latitude is positive and night-over-day once it turns negative after the September
* equinox. Anchoring lo to the top pixel's side keeps the search valid either way.
*/ */
private fun findCrossingY( private fun findCrossingY(
proj: org.osmdroid.views.Projection, proj: org.osmdroid.views.Projection,
@@ -136,16 +148,18 @@ class MapNightOverlay : Overlay() {
yBot: Int, yBot: Int,
sinSunLat: Double, sinSunLat: Double,
cosSunLat: Double, cosSunLat: Double,
cosLonDiff: Double cosLonDiff: Double,
isNightAtTop: Boolean
): Int { ): Int {
var lo = yTop var lo = yTop
var hi = yBot var hi = yBot
while (hi - lo > 1) { while (hi - lo > 1) {
val mid = (lo + hi) / 2 val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
val latRad = Math.toRadians(geo.latitude) val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid // Keep lo on the top pixel's side of the terminator and hi on the opposite side
if ((dot < 0) == isNightAtTop) lo = mid else hi = mid
} }
return (lo + hi) / 2 return (lo + hi) / 2
} }
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
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.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
@@ -71,6 +70,10 @@ 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.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
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.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
@@ -119,6 +122,39 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN) android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
} }
/** Accent used for the station marker; recolored per theme so it stays visible on a light map. */
private var mapAccentColor = "#FFE082".toColorInt()
/** The dark-map filter (grayscale + invert) is only applied when NOT in light theme; cached to avoid rebuilds. */
private val darkTileFilter by lazy { createColorFilter() }
/**
* Recolor the shared overlay paints for the current theme. The light OSM tiles are white, so the
* dark-theme amber/grey overlays would vanish; light theme swaps them for dark, high-contrast colors.
* Must run before the set*() overlay builders on each frame, as several icons cache the paint color.
*/
private fun applyMapColors(isLightUi: Boolean) {
if (isLightUi) {
mapAccentColor = "#715C0C".toColorInt() // dark amber (matches app lightScheme primary)
footprintPaint.color = mapAccentColor
textPaint.color = "#1E1B13".toColorInt()
textPaint.setShadowLayer(3f, 3f, 3f, Color.WHITE)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter("#4C4639".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
} else {
mapAccentColor = "#FFE082".toColorInt()
footprintPaint.color = mapAccentColor
textPaint.color = mapAccentColor
textPaint.setShadowLayer(3f, 3f, 3f, Color.BLACK)
sunIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter(mapAccentColor, android.graphics.PorterDuff.Mode.SRC_IN)
moonIconPaint.colorFilter =
android.graphics.PorterDuffColorFilter("#E0E0E0".toColorInt(), android.graphics.PorterDuff.Mode.SRC_IN)
}
}
@Composable @Composable
fun MapDestination() { fun MapDestination() {
val context = LocalContext.current val context = LocalContext.current
@@ -126,6 +162,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container)) val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
} }
@@ -159,6 +210,8 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
ElevatedCard(modifier = Modifier.weight(1f)) { ElevatedCard(modifier = Modifier.weight(1f)) {
Box(contentAlignment = Alignment.BottomCenter) { Box(contentAlignment = Alignment.BottomCenter) {
AndroidView({ mapView }) { view -> AndroidView({ mapView }) { view ->
applyMapColors(uiState.isLightUi)
view.overlayManager.tilesOverlay.setColorFilter(if (uiState.isLightUi) null else darkTileFilter)
uiState.stationPosition?.let { setStationPosition(it, view) } uiState.stationPosition?.let { setStationPosition(it, view) }
uiState.track?.let { setSatelliteTrack(it, view) } uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) } uiState.footprint?.let { setFootprint(it, view) }
@@ -270,7 +323,9 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply { mapView.overlays[OVERLAY_STATION] = Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position) icon = ContextCompat.getDrawable(mapView.context, R.drawable.ic_position)?.apply {
setTint(mapAccentColor)
}
position = GeoPoint(stationPos.latitude, stationPos.longitude) position = GeoPoint(stationPos.latitude, stationPos.longitude)
} }
} }
@@ -283,19 +338,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>() private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null private var lastMapView: MapView? = null
/**
* Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
*/
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
private fun setPositions( private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>, posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView, mapView: MapView,
action: (OrbitalObject) -> Unit action: (OrbitalObject) -> Unit
) { ) {
try { try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change) // Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) { if (lastMapView !== mapView) {
lastMapView = mapView lastMapView = mapView
markerPool.clear() markerPool.clear()
iconCache.evictAll() iconCache.evictAll()
dotIcon = null
footprintPolyline = null footprintPolyline = null
footprintPoints = null footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
} }
// 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.
@@ -304,17 +389,48 @@ private fun setPositions(
} }
folder.items.clear() folder.items.clear()
val activeNames = HashSet<String>(posMap.size) // Cull satellites outside the viewport: only meaningful once zoomed in, but that is
posMap.forEach { (satellite, geoPos) -> // exactly when marker labels are shown and drawing is most expensive.
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name val name = satellite.data.name
activeNames.add(name) activeNames.add(name)
val marker = markerPool.getOrPut(name) { val marker = markerPool.getOrPut(name) {
Marker(mapView).apply { Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView) // Resolve the satellite via relatedObject so the listener is allocated
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
} }
} }
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available // Update position in-place — reuse existing GeoPoint if available
val pos = marker.position val pos = marker.position
if (pos != null) { if (pos != null) {
@@ -323,22 +439,32 @@ private fun setPositions(
} else { } else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude) marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
} }
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker) folder.add(marker)
} }
// Evict markers for satellites no longer tracked // Evict markers that are no longer tracked or no longer visible
val iter = markerPool.keys.iterator() markerPool.keys.retainAll(activeNames)
while (iter.hasNext()) { visibleSats.clear()
if (iter.next() !in activeNames) iter.remove()
}
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable { private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it } iconCache[name]?.let { return it }
val labelRect = Rect() val labelRect = Rect()
@@ -376,9 +502,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
try { try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) { if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size) pts = ArrayList(rangeCircle.size)
@@ -497,7 +622,7 @@ private fun rememberMapViewWithLifecycle(): MapView {
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()) // Tile color filter (dark inversion vs light) is applied per-theme in the AndroidView update block.
setScrollableAreaLimitLatitude(maxLat, minLat, 0) setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() }) overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
} }
@@ -508,9 +633,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver) lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) } onDispose { lifecycle.removeObserver(lifecycleObserver) }
} }
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose {
clearMapCaches()
mapView.onDetach()
}
}
return mapView return mapView
} }
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable @Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
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.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState( data class MapState(
val mapData: MapData? = null, val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
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,
val footprint: OrbitalPos? = null, val footprint: List<GeoPos>? = null,
val positions: Map<OrbitalObject, GeoPos>? = null, val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0, val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0, val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
} }
data class MapData( data class MapData(
@@ -36,6 +36,7 @@ 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel( class MapViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -69,12 +72,15 @@ class MapViewModel(
private var dataUpdateJob: Job? = null private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
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, isLightUi = settings.stateOfLightTheme) }
} }
} }
val (selectedCatNum, _) = satelliteRepo.selectedPass.value val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false) MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
} }
} }
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject selectedOrbitalObject = orbitalObject
viewModelScope.launch { viewModelScope.launch {
dataUpdateJob?.cancelAndJoin() dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch { // Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq else -> updateFreq
} }
while (isActive) { while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate) delay(effectiveRate.milliseconds)
} }
} }
} }
@@ -187,7 +200,8 @@ class MapViewModel(
// 2. Derive footprint, info data, sun and moon position from already-computed state // 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time) val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos // Range circle is 721 trig-heavy points — keep it on this background dispatcher
val footprint = satPos.getRangeCircle()
val mapData = buildMapData(selected, satPos, date) val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time) val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time) val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
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 val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
@@ -31,9 +31,12 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@@ -115,6 +118,7 @@ private fun RadarScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.layoutPadding() .layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(), .keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
@@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
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.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -78,12 +79,12 @@ internal fun SstvPage(
) { ) {
Text(text = "🎙️", fontSize = 48.sp) Text(text = "🎙️", fontSize = 48.sp)
Text( Text(
text = "Microphone access needed", text = stringResource(R.string.sstv_mic_permission),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Button(onClick = requestMicPermission) { Button(onClick = requestMicPermission) {
Text("Grant permission") Text(stringResource(R.string.sstv_grant_permission))
} }
} }
} }
@@ -97,7 +98,7 @@ internal fun SstvPage(
val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes } val allModes = remember(sstv.supportedModes) { listOf("Auto") + sstv.supportedModes }
val dismiss = { showModeDialog.value = false } val dismiss = { showModeDialog.value = false }
ConfirmDialog( ConfirmDialog(
title = "SSTV Mode", title = stringResource(R.string.sstv_mode_title),
onAccept = dismiss, onAccept = dismiss,
onCancel = dismiss onCancel = dismiss
) { ) {
@@ -167,7 +168,7 @@ internal fun SstvPage(
) )
} else { } else {
Text( Text(
text = if (sstv.status == SstvStatus.Recording) "Listening…" else "No signal", text = if (sstv.status == SstvStatus.Recording) stringResource(R.string.sstv_listening) else stringResource(R.string.sstv_no_signal),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
@@ -196,7 +197,7 @@ internal fun SstvPage(
) { ) {
// Doppler-corrected downlink frequency hint // Doppler-corrected downlink frequency hint
OutlinedText( OutlinedText(
text = dopplerFrequency?.let { "RX: $it Hz" } ?: "No transceiver selected", text = dopplerFrequency?.let { stringResource(R.string.sstv_rx_format, it) } ?: stringResource(R.string.sstv_no_transceiver),
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fillColor = MaterialTheme.colorScheme.primary, fillColor = MaterialTheme.colorScheme.primary,
@@ -227,7 +228,7 @@ internal fun SstvPage(
) { ) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = "Mode: ${sstv.selectedMode}", text = stringResource(R.string.sstv_mode_format, sstv.selectedMode),
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@@ -403,7 +403,7 @@ private fun ExpandedRadioControl(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = "TX Base: ", text = stringResource(R.string.radar_tx_base),
fontSize = 14.sp, fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -460,7 +460,7 @@ private fun ExpandedRadioControl(
onClick = { onAction(RadarAction.SetCtcssTone(null)) }, onClick = { onAction(RadarAction.SetCtcssTone(null)) },
label = { label = {
Text( Text(
text = "Off", text = stringResource(R.string.radar_off),
fontSize = 12.sp, fontSize = 12.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -492,19 +492,19 @@ private fun ExpandedRadioControl(
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) { if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
CardButton( CardButton(
onClick = { onAction(RadarAction.ConnectRadios) }, onClick = { onAction(RadarAction.ConnectRadios) },
text = "Connect", text = stringResource(R.string.radar_connect),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} else { } else {
CardButton( CardButton(
onClick = { onAction(RadarAction.DisconnectRadios) }, onClick = { onAction(RadarAction.DisconnectRadios) },
text = "Disconnect", text = stringResource(R.string.radar_disconnect),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
CardButton( CardButton(
onClick = { onAction(RadarAction.ToggleTracking) }, onClick = { onAction(RadarAction.ToggleTracking) },
text = if (radioControl.isTracking) "Stop" else "Track", text = if (radioControl.isTracking) stringResource(R.string.radar_stop) else stringResource(R.string.radar_track),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
@@ -726,7 +726,7 @@ private fun DopplerFrequencyCalculator(
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
if (offsetKHz.isEmpty()) { if (offsetKHz.isEmpty()) {
Text( Text(
text = "Offset (kHz)", text = stringResource(R.string.radar_doppler_offset_hint),
fontSize = 16.sp, fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -17,7 +17,8 @@
*/ */
package com.rtbishop.look4sat.feature.settings package com.rtbishop.look4sat.feature.settings
import androidx.compose.animation.core.spring import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,9 +30,12 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -39,17 +43,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField 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.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color
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
@@ -60,12 +64,19 @@ import androidx.compose.ui.unit.sp
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.Constants import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.source.NetworkResult
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.presentation.CardButton import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.DragReorderState
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.LocalSpacing import com.rtbishop.look4sat.core.presentation.LocalSpacing
import com.rtbishop.look4sat.core.presentation.MainTheme import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ConfirmDialog import com.rtbishop.look4sat.core.presentation.ConfirmDialog
import com.rtbishop.look4sat.core.presentation.dragHandle
import com.rtbishop.look4sat.core.presentation.dragLift
import com.rtbishop.look4sat.core.presentation.rememberDragReorderState
import com.rtbishop.look4sat.core.presentation.rememberDragRowState
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -152,44 +163,94 @@ private fun TransceiversDialogPreview() {
transceiversUrls = listOf( transceiversUrls = listOf(
"db.satnogs.org/api/transmitters/?format=json&status=active" "db.satnogs.org/api/transmitters/?format=json&status=active"
), ),
satelliteEnabled = listOf(true, false),
transceiversEnabled = listOf(true),
statusCodes = mapOf(
"celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv" to 200,
"amsat.org/tle/current/nasabare.txt" to 404
),
onImportTle = {}, onImportTle = {},
onImportTransceivers = {}, onImportTransceivers = {},
onDismiss = {}, onDismiss = {},
onSave = { _, _ -> } onSave = { _, _, _, _ -> }
) )
} }
} }
/**
* Single source of truth for one data-source row: url, enabled flag and stable id all live
* together so a reorder, a toggle or an edit is a single list mutation instead of several parallel
* lists/maps having to stay in sync (which used to fan out into extra recompositions).
*/
private data class SourceEntry(val id: Long, val url: String, val enabled: Boolean = true)
@Composable @Composable
fun DataSourcesDialog( fun DataSourcesDialog(
satelliteUrls: List<String>, satelliteUrls: List<String>,
transceiversUrls: List<String>, transceiversUrls: List<String>,
satelliteEnabled: List<Boolean>,
transceiversEnabled: List<Boolean>,
statusCodes: Map<String, Int>,
onImportTle: () -> Unit, onImportTle: () -> Unit,
onImportTransceivers: () -> Unit, onImportTransceivers: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSave: (List<String>, List<String>) -> Unit onSave: (List<String>, List<String>, List<Boolean>, List<Boolean>) -> Unit
) { ) {
val padding = LocalSpacing.current.large val padding = LocalSpacing.current.large
// Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries). // Use stable Long IDs to avoid key collisions (e.g. multiple empty "" entries).
val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) } val nextId = remember { mutableLongStateOf((satelliteUrls.size + transceiversUrls.size).toLong()) }
val satUrls = remember { val satEntries = remember {
satelliteUrls.mapIndexed { i, url -> i.toLong() to url }.toMutableStateList() satelliteUrls.mapIndexed { i, url ->
SourceEntry(i.toLong(), url, satelliteEnabled.getOrElse(i) { true })
}.toMutableStateList()
} }
val txUrls = remember { val txEntries = remember {
transceiversUrls.mapIndexed { i, url -> (satelliteUrls.size + i).toLong() to url }.toMutableStateList() transceiversUrls.mapIndexed { i, url ->
SourceEntry((satelliteUrls.size + i).toLong(), url, transceiversEnabled.getOrElse(i) { true })
}.toMutableStateList()
} }
val onAccept = { onSave(satUrls.map { it.second }, txUrls.map { it.second }); onDismiss() } val listState = rememberLazyListState()
val dragState = rememberDragReorderState(listState)
val onRestoreSatDefaults = {
satEntries.clear()
satEntries.addAll(Sources.satelliteDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
}
val onRestoreTxDefaults = {
txEntries.clear()
txEntries.addAll(Sources.transceiversDataUrls.map { url -> SourceEntry(nextId.longValue++, url) })
Unit
}
val onAccept = {
val satFiltered = satEntries.filter { it.url.isNotBlank() }
val txFiltered = txEntries.filter { it.url.isNotBlank() }
try {
onSave(
satFiltered.map { it.url },
txFiltered.map { it.url },
satFiltered.map { it.enabled },
txFiltered.map { it.enabled }
)
} finally {
onDismiss()
}
}
val satTitle = stringResource(R.string.prefs_data_sources_sat_title)
val satHint = stringResource(R.string.prefs_data_sources_sat_hint)
val transceiversTitle = stringResource(R.string.prefs_data_sources_transceivers_title)
val transceiversHint = stringResource(R.string.prefs_data_sources_transceivers_hint)
ConfirmDialog( ConfirmDialog(
title = stringResource(id = R.string.prefs_data_sources_title), title = stringResource(id = R.string.prefs_data_sources_title),
onCancel = onDismiss, onCancel = onDismiss,
onAccept = onAccept, onAccept = onAccept,
) { ) {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.fillMaxHeight(0.84f) .fillMaxHeight(0.84f)
.padding(horizontal = padding), .padding(horizontal = padding),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 6.dp) contentPadding = PaddingValues(vertical = 0.dp)
) { ) {
item { item {
Row( Row(
@@ -210,21 +271,31 @@ fun DataSourcesDialog(
} }
sourceSection( sourceSection(
sectionKey = "sat", sectionKey = "sat",
label = "Satellites data", label = satTitle,
urls = satUrls, hint = satHint,
onAdd = { satUrls.add(nextId.longValue++ to "") }, entries = satEntries,
onMoveUp = { i -> if (i > 0) satUrls.add(i - 1, satUrls.removeAt(i)) }, dragState = dragState,
onRemove = { i -> satUrls.removeAt(i) }, statusCodes = statusCodes,
onUrlChange = { i, v -> satUrls[i] = satUrls[i].first to v } onAdd = { satEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreSatDefaults,
onMove = { from, to -> satEntries.add(to, satEntries.removeAt(from)) },
onRemove = { i -> satEntries.removeAt(i) },
onToggle = { i -> satEntries[i] = satEntries[i].copy(enabled = !satEntries[i].enabled) },
onUrlChange = { i, v -> satEntries[i] = satEntries[i].copy(url = v) }
) )
sourceSection( sourceSection(
sectionKey = "tx", sectionKey = "tx",
label = "Transceivers data", label = transceiversTitle,
urls = txUrls, hint = transceiversHint,
onAdd = { txUrls.add(nextId.longValue++ to "") }, entries = txEntries,
onMoveUp = { i -> if (i > 0) txUrls.add(i - 1, txUrls.removeAt(i)) }, dragState = dragState,
onRemove = { i -> txUrls.removeAt(i) }, statusCodes = statusCodes,
onUrlChange = { i, v -> txUrls[i] = txUrls[i].first to v } onAdd = { txEntries.add(SourceEntry(nextId.longValue++, "")) },
onRestore = onRestoreTxDefaults,
onMove = { from, to -> txEntries.add(to, txEntries.removeAt(from)) },
onRemove = { i -> txEntries.removeAt(i) },
onToggle = { i -> txEntries[i] = txEntries[i].copy(enabled = !txEntries[i].enabled) },
onUrlChange = { i, v -> txEntries[i] = txEntries[i].copy(url = v) }
) )
} }
} }
@@ -233,10 +304,15 @@ fun DataSourcesDialog(
private fun LazyListScope.sourceSection( private fun LazyListScope.sourceSection(
sectionKey: String, sectionKey: String,
label: String, label: String,
urls: List<Pair<Long, String>>, hint: String,
entries: SnapshotStateList<SourceEntry>,
dragState: DragReorderState,
statusCodes: Map<String, Int>,
onAdd: () -> Unit, onAdd: () -> Unit,
onMoveUp: (Int) -> Unit, onRestore: () -> Unit,
onMove: (Int, Int) -> Unit,
onRemove: (Int) -> Unit, onRemove: (Int) -> Unit,
onToggle: (Int) -> Unit,
onUrlChange: (Int, String) -> Unit onUrlChange: (Int, String) -> Unit
) { ) {
item { item {
@@ -248,39 +324,107 @@ private fun LazyListScope.sourceSection(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconCard(action = onAdd, resId = R.drawable.ic_add, containerColor = MaterialTheme.colorScheme.surfaceVariant) IconCard(
action = onRestore,
resId = R.drawable.ic_restore,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
Spacer(modifier = Modifier.width(6.dp))
IconCard(
action = onAdd,
resId = R.drawable.ic_add,
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
} }
} Text(
itemsIndexed(urls, key = { _, entry -> "$sectionKey-${entry.first}" }) { index, (_, url) -> text = hint,
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
OutlinedTextField(
value = url,
onValueChange = { onUrlChange(index, it) },
label = { Text("Source URL") },
leadingIcon = {
IconButton(onClick = { onMoveUp(index) }) {
Icon(
painter = painterResource(R.drawable.ic_arrow),
contentDescription = null,
tint = if (index > 0) enabledTint else enabledTint.copy(alpha = 0.32f),
modifier = Modifier.rotate(270f)
)
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateItem(fadeInSpec = spring(), fadeOutSpec = spring()) .padding(top = 2.dp, bottom = 8.dp)
) )
} }
itemsIndexed(entries, key = { _, entry -> "$sectionKey-${entry.id}" }) { index, entry ->
val enabledTint = MaterialTheme.colorScheme.onSurfaceVariant
val rowState = rememberDragRowState(
dragState = dragState,
items = entries,
item = entry,
key = { "$sectionKey-${it.id}" },
onMove = onMove
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.dragLift(isLifted = rowState.isLifted, translationY = rowState.translationY)
.animateItem(
fadeInSpec = tween(durationMillis = 200),
// The dragged row repositions instantly, while its neighbors smoothly
// slide out of the way (the "squeeze" effect).
placementSpec = if (rowState.isDragging) {
tween(durationMillis = 0)
} else {
tween(durationMillis = 250, easing = FastOutSlowInEasing)
},
fadeOutSpec = tween(durationMillis = 200)
)
) {
Icon(
painter = painterResource(R.drawable.ic_drag_handle),
contentDescription = null,
tint = enabledTint,
modifier = Modifier
.padding(top = textFieldLabelOffset)
.dragHandle(rowState)
)
Spacer(modifier = Modifier.width(12.dp))
OutlinedTextField(
value = entry.url,
onValueChange = { onUrlChange(index, it) },
label = {
Row {
Text("Source URL")
statusCodes[entry.url]?.let { code ->
Text(" - ${statusLabel(code)}", color = statusColor(code), fontSize = 12.sp)
}
}
},
trailingIcon = {
IconButton(onClick = { onRemove(index) }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null
)
}
},
singleLine = true,
enabled = entry.enabled,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = entry.enabled,
onCheckedChange = { onToggle(index) },
modifier = Modifier.padding(top = textFieldLabelOffset)
)
}
}
}
/**
* Compensates for the visual weight of the [OutlinedTextField]'s floating label: the label
* pushes the perceived center of the field's content down a bit, so the drag handle and
* checkbox are nudged down by the same amount to stay visually centered on the input line.
*/
private val textFieldLabelOffset = 8.dp
private fun statusLabel(code: Int): String = if (code == NetworkResult.CONNECTION_ERROR) "ERR" else code.toString()
@Composable
private fun statusColor(code: Int): Color = when (code) {
NetworkResult.CONNECTION_ERROR -> MaterialTheme.colorScheme.error
in 200..299 -> Color(0xFF66BB6A)
else -> MaterialTheme.colorScheme.error
} }
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -648,7 +792,7 @@ fun RadioControlDialog(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = "Split mode (IC-705 only)", text = stringResource(R.string.settings_split_mode),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = splitModeLabelColor color = splitModeLabelColor
) )
@@ -688,7 +832,7 @@ fun RadioControlDialog(
if (selectingFor.value.isNotBlank()) { if (selectingFor.value.isNotBlank()) {
Text( Text(
text = "Paired Bluetooth Devices:", text = stringResource(R.string.settings_paired_devices),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
@@ -120,11 +120,19 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
DataSourcesDialog( DataSourcesDialog(
satelliteUrls = uiState.dataSourcesSettings.satelliteUrls, satelliteUrls = uiState.dataSourcesSettings.satelliteUrls,
transceiversUrls = uiState.dataSourcesSettings.transceiversUrls, transceiversUrls = uiState.dataSourcesSettings.transceiversUrls,
satelliteEnabled = uiState.dataSourcesSettings.satelliteEnabled,
transceiversEnabled = uiState.dataSourcesSettings.transceiversEnabled,
statusCodes = uiState.dataSourcesStatus,
onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false }, onImportTle = { permissions.launchTleImport(); dialogs.dataSources = false },
onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false }, onImportTransceivers = { permissions.launchTransceiverImport(); dialogs.dataSources = false },
onDismiss = { dialogs.dataSources = false }, onDismiss = { dialogs.dataSources = false },
onSave = { satUrls, txUrls -> onSave = { satUrls, txUrls, satEnabled, txEnabled ->
val newSettings = DataSourcesSettings(satelliteUrls = satUrls, transceiversUrls = txUrls) val newSettings = DataSourcesSettings(
satelliteUrls = satUrls,
transceiversUrls = txUrls,
satelliteEnabled = satEnabled,
transceiversEnabled = txEnabled
)
if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings)) if (newSettings != uiState.dataSourcesSettings) onAction(SettingsAction.UpdateDataSources(newSettings))
onAction(SettingsAction.UpdateFromWeb) onAction(SettingsAction.UpdateFromWeb)
} }
@@ -180,7 +188,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
// URLs for top bar // URLs for top bar
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val appUrl = stringResource(R.string.prefs_app_url) // val appUrl = stringResource(R.string.prefs_app_url)
val donateUrl = stringResource(R.string.prefs_donate_url) val donateUrl = stringResource(R.string.prefs_donate_url)
val fdroidTitle = stringResource(R.string.prefs_fdroid_title) val fdroidTitle = stringResource(R.string.prefs_fdroid_title)
val fdroidUrl = stringResource(R.string.prefs_fdroid_url) val fdroidUrl = stringResource(R.string.prefs_fdroid_url)
@@ -201,7 +209,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
version = uiState.appVersionName, version = uiState.appVersionName,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
} }
TopBar { TopBar {
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -223,7 +231,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_like) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard( TopCard(
onClick = { dialogs.whatsNew = true }, onClick = { dialogs.whatsNew = true },
version = uiState.appVersionName, version = uiState.appVersionName,
@@ -334,9 +342,9 @@ private fun LocationCard(
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp)) Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = stringResource(R.string.prefs_lat_prefix, settings.stationPos.latitude))
Text(text = "Lon: ${settings.stationPos.longitude}°") Text(text = stringResource(R.string.prefs_lon_prefix, settings.stationPos.longitude))
Text(text = "Qth: ${settings.stationPos.qthLocator}") Text(text = stringResource(R.string.prefs_qth_prefix, settings.stationPos.qthLocator))
} }
Spacer(modifier = Modifier.height(1.dp)) Spacer(modifier = Modifier.height(1.dp))
Row(horizontalArrangement = Arrangement.SpaceEvenly) { Row(horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -487,17 +495,31 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Display preferences: UTC clock + night filter // Data preferences: auto-update + UTC clock
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) { SwitchTile(R.string.prefs_other_switch_utc, settings.stateOfUtc) {
onAction(SettingsAction.ToggleUtc(it)) onAction(SettingsAction.ToggleUtc(it))
} }
SwitchTile(R.string.prefs_other_switch_night_mode, settings.stateOfNightMode) { }
Spacer(modifier = Modifier.height(4.dp))
// Appearance: light theme + night filter
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_light_theme, settings.stateOfLightTheme) {
onAction(SettingsAction.ToggleLightTheme(it))
}
SwitchTile(
R.string.prefs_other_switch_night_mode,
settings.stateOfNightMode,
enabled = !settings.stateOfLightTheme
) {
onAction(SettingsAction.ToggleNightMode(it)) onAction(SettingsAction.ToggleNightMode(it))
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Radar behavior: sweep animation + sensor control // Radar behavior: sweep animation + sensors, followed by the compass offset sliders
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) { SwitchTile(R.string.prefs_other_switch_sweep, settings.stateOfSweep) {
onAction(SettingsAction.ToggleSweep(it)) onAction(SettingsAction.ToggleSweep(it))
@@ -507,14 +529,9 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
} }
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Data management (full width)
SwitchRow(R.string.prefs_other_switch_update, settings.stateOfAutoUpdate) {
onAction(SettingsAction.ToggleUpdate(it))
}
Spacer(modifier = Modifier.height(4.dp))
// Compass calibration sliders at the bottom // Compass calibration sliders at the bottom
CompassOffsetRow( CompassOffsetRow(
labelResId = R.string.prefs_other_compass_offset, labelResId = R.string.prefs_other_compass_offset_az,
value = settings.radarCompassOffset, value = settings.radarCompassOffset,
range = -180f..180f range = -180f..180f
) { onAction(SettingsAction.SetRadarCompassOffset(it)) } ) { onAction(SettingsAction.SetRadarCompassOffset(it)) }
@@ -541,7 +558,14 @@ private fun CompassOffsetRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(text = stringResource(id = labelResId)) Text(
text = stringResource(id = labelResId),
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.infiniteMarquee(),
maxLines = 1
)
Text(text = "${value.toInt()}°") Text(text = "${value.toInt()}°")
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -553,19 +577,12 @@ private fun CompassOffsetRow(
} }
@Composable @Composable
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { private fun RowScope.SwitchTile(
Row( labelResId: Int,
horizontalArrangement = Arrangement.SpaceBetween, checked: Boolean,
verticalAlignment = Alignment.CenterVertically, enabled: Boolean = true,
modifier = Modifier.fillMaxWidth() onCheckedChange: (Boolean) -> Unit
) { ) {
Text(text = stringResource(id = labelResId))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -573,9 +590,13 @@ private fun RowScope.SwitchTile(labelResId: Int, checked: Boolean, onCheckedChan
) { ) {
Text( Text(
text = stringResource(id = labelResId), text = stringResource(id = labelResId),
modifier = Modifier.weight(1f) modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.infiniteMarquee(),
maxLines = 1
) )
Switch(checked = checked, onCheckedChange = onCheckedChange) Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange)
} }
} }
@@ -42,6 +42,7 @@ data class SettingsState(
val rcSettings: RCSettings, val rcSettings: RCSettings,
val radioControlSettings: RadioControlSettings, val radioControlSettings: RadioControlSettings,
val dataSourcesSettings: DataSourcesSettings, val dataSourcesSettings: DataSourcesSettings,
val dataSourcesStatus: Map<String, Int> = emptyMap(),
val pairedBluetoothDevices: List<Pair<String, String>> = emptyList() val pairedBluetoothDevices: List<Pair<String, String>> = emptyList()
) )
@@ -49,6 +49,7 @@ class SettingsViewModel(
rcSettings = settingsRepo.rcSettings.value, rcSettings = settingsRepo.rcSettings.value,
radioControlSettings = settingsRepo.radioControlSettings.value, radioControlSettings = settingsRepo.radioControlSettings.value,
dataSourcesSettings = settingsRepo.dataSourcesSettings.value, dataSourcesSettings = settingsRepo.dataSourcesSettings.value,
dataSourcesStatus = settingsRepo.dataSourcesStatus.value,
pairedBluetoothDevices = container.providePairedBluetoothDevices() pairedBluetoothDevices = container.providePairedBluetoothDevices()
) )
) )
@@ -92,6 +93,11 @@ class SettingsViewModel(
_uiState.update { it.copy(dataSourcesSettings = settings) } _uiState.update { it.copy(dataSourcesSettings = settings) }
} }
} }
viewModelScope.launch {
settingsRepo.dataSourcesStatus.collect { status ->
_uiState.update { it.copy(dataSourcesStatus = status) }
}
}
viewModelScope.launch { viewModelScope.launch {
settingsRepo.radioControlSettings.collect { settings -> settingsRepo.radioControlSettings.collect { settings ->
_uiState.update { it.copy(radioControlSettings = settings) } _uiState.update { it.copy(radioControlSettings = settings) }
@@ -121,7 +127,10 @@ class SettingsViewModel(
is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) } is SettingsAction.ToggleUpdate -> settingsRepo.updateOtherSettings { it.copy(stateOfAutoUpdate = action.value) }
is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) } is SettingsAction.ToggleSweep -> settingsRepo.updateOtherSettings { it.copy(stateOfSweep = action.value) }
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 {
// Night filter (red-only overlay) is incompatible with the light theme, so turn it off.
it.copy(stateOfLightTheme = action.value, stateOfNightMode = if (action.value) false else it.stateOfNightMode)
}
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) } is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) } is SettingsAction.SetRadarCompassOffset -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffset = action.value) }
is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) } is SettingsAction.SetRadarCompassOffsetElev -> settingsRepo.updateOtherSettings { it.copy(radarCompassOffsetElev = action.value) }
@@ -200,7 +209,6 @@ class SettingsViewModel(
} }
} }
// endregion // endregion
companion object { companion object {
-10
View File
@@ -1,17 +1,7 @@
import com.android.build.api.dsl.LibraryExtension
plugins { plugins {
alias(libs.plugins.convention.featurePlugin) alias(libs.plugins.convention.featurePlugin)
} }
android { android {
namespace = "com.rtbishop.look4sat.feature.status" namespace = "com.rtbishop.look4sat.feature.status"
compileOptions {
encoding = "UTF-8"
}
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:presentation"))
} }
@@ -1,5 +1,6 @@
package com.rtbishop.look4sat.feature.status package com.rtbishop.look4sat.feature.status
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -20,9 +21,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -51,14 +57,32 @@ import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.CardButton
import com.rtbishop.look4sat.core.presentation.InfoDialog import com.rtbishop.look4sat.core.presentation.InfoDialog
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */ /** Fixed width per day tile — tablet-safe; name column absorbs remaining space. */
private val TILE_WIDTH: Dp = 64.dp private val TILE_WIDTH: Dp = 64.dp
private data class UploadOption(
val apiValue: String,
val labelResId: Int,
val color: Color
)
private val UPLOAD_OPTIONS = listOf(
UploadOption(AMSAT_REPORT_HEARD, R.string.amsat_upload_active, Color(0xFF648FFF)),
UploadOption(AMSAT_REPORT_TELEMETRY_ONLY, R.string.amsat_upload_tlm, Color(0xFFFFB000)),
UploadOption(AMSAT_REPORT_NOT_HEARD, R.string.amsat_upload_not_heard, Color(0xFFDC267F)),
UploadOption(AMSAT_REPORT_CREW_ACTIVE, R.string.amsat_upload_crew_active, Color(0xFFFE6100))
)
/** /**
* Map AMSAT status text to Material3 colorScheme colors. * Map AMSAT status text to Material3 colorScheme colors.
* Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants.
@@ -77,17 +101,52 @@ private fun statusColorOf(statusText: String): Color {
} }
} }
/** Localize AMSAT API status text for the current locale; falls back to the raw text. */
@Composable
private fun statusTextOf(statusText: String): String {
if (Locale.getDefault().language != Locale.CHINESE.language) return statusText
return when (statusText.lowercase()) {
"heard" -> stringResource(R.string.amsat_status_heard)
"telemetry only" -> stringResource(R.string.amsat_status_tlm_only)
"not heard" -> stringResource(R.string.amsat_status_not_heard)
"crew active" -> stringResource(R.string.amsat_status_crew_active)
else -> statusText
}
}
@Composable @Composable
fun SatStatusDestination() { fun SatStatusDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container)) val viewModel: SatStatusViewModel = viewModel(factory = SatStatusViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SatStatusScreen(uiState) { viewModel.refresh() } SatStatusScreen(
uiState = uiState,
refresh = viewModel::refresh,
onToggleUpload = viewModel::toggleUploadPanel,
onUploadReportChange = viewModel::setUploadReport,
onUploadCallsignChange = viewModel::setUploadCallsign,
onUploadGridChange = viewModel::setUploadGrid,
onRequestSubmitUpload = viewModel::requestSubmitConfirmation,
onConfirmSubmitUpload = viewModel::confirmSubmitReport,
onDismissSubmitConfirmation = viewModel::dismissSubmitConfirmation,
onDismissUpload = viewModel::collapseUploadPanel
)
} }
@Composable @Composable
private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) { private fun SatStatusScreen(
uiState: SatStatusUiState,
refresh: () -> Unit,
onToggleUpload: () -> Unit,
onUploadReportChange: (String) -> Unit,
onUploadCallsignChange: (String) -> Unit,
onUploadGridChange: (String) -> Unit,
onRequestSubmitUpload: () -> Unit,
onConfirmSubmitUpload: (String) -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismissUpload: () -> Unit
) {
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) } var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
Column(modifier = Modifier.fillMaxSize().layoutPadding()) { Column(modifier = Modifier.fillMaxSize().layoutPadding()) {
StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh) StatusHeader(fetchedAtUtcMs = uiState.fetchedAtUtcMs, isRefreshing = uiState.isRefreshing, onRefresh = refresh)
@@ -122,7 +181,23 @@ private fun SatStatusScreen(uiState: SatStatusUiState, refresh: () -> Unit) {
} }
selectedDay?.let { (status, day) -> selectedDay?.let { (status, day) ->
ReportDialog(statusName = status.name, day = day, reports = uiState.reports, onDismiss = { selectedDay = null }) ReportDialog(
statusName = status.name,
day = day,
reports = uiState.reports,
upload = uiState.upload,
onToggleUpload = onToggleUpload,
onReportChange = onUploadReportChange,
onCallsignChange = onUploadCallsignChange,
onGridChange = onUploadGridChange,
onRequestSubmitReport = onRequestSubmitUpload,
onConfirmSubmitReport = { onConfirmSubmitUpload(status.name) },
onDismissSubmitConfirmation = onDismissSubmitConfirmation,
onDismiss = {
selectedDay = null
onDismissUpload()
}
)
} }
} }
@@ -276,13 +351,57 @@ private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
} }
} }
/** Report detail dialog (callsign / date / time / grid) */ /** Report detail dialog (callsign / date / time / grid) + AMSAT report upload panel. */
@Composable @Composable
private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, SatReport>, onDismiss: () -> Unit) { private fun ReportDialog(
statusName: String,
day: SatDay,
reports: Map<String, SatReport>,
upload: AmSatUploadUiState,
onToggleUpload: () -> Unit,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit,
onConfirmSubmitReport: () -> Unit,
onDismissSubmitConfirmation: () -> Unit,
onDismiss: () -> Unit
) {
val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] } val dayReports = day.slots.flatMap { it.reportIds }.mapNotNull { reports[it] }
InfoDialog(title = "$statusName · ${day.dateLabel}", onDismiss = onDismiss, onAccept = onDismiss) { InfoDialog(
title = "$statusName · ${day.dateLabel}",
onDismiss = onDismiss,
onAccept = onDismiss,
extraAction = {
CardButton(
onClick = onToggleUpload,
text = stringResource(if (upload.isExpanded) R.string.amsat_upload_back else R.string.amsat_upload)
)
}
) {
AnimatedVisibility(visible = upload.isExpanded) {
AmSatUploadPanel(
statusName = statusName,
upload = upload,
onReportChange = onReportChange,
onCallsignChange = onCallsignChange,
onGridChange = onGridChange,
onRequestSubmitReport = onRequestSubmitReport
)
}
if (upload.isConfirmingSubmit) {
AmSatUploadConfirmDialog(
statusName = statusName,
upload = upload,
onConfirm = onConfirmSubmitReport,
onDismiss = onDismissSubmitConfirmation
)
}
if (dayReports.isEmpty()) { if (dayReports.isEmpty()) {
Text(stringResource(id = R.string.amsat_no_reports)) Text(
text = stringResource(id = R.string.amsat_no_reports),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} else { } else {
LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) { LazyColumn(modifier = Modifier.heightIn(max = 480.dp)) {
@@ -296,7 +415,7 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
.background(statusColorOf(report.statusText)) .background(statusColorOf(report.statusText))
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text(text = report.statusText, fontSize = 14.sp, fontWeight = FontWeight.Bold) Text(text = statusTextOf(report.statusText), fontSize = 14.sp, fontWeight = FontWeight.Bold)
} }
Text( Text(
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" + text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
@@ -312,16 +431,192 @@ private fun ReportDialog(statusName: String, day: SatDay, reports: Map<String, S
} }
} }
@Composable
private fun AmSatUploadPanel(
statusName: String,
upload: AmSatUploadUiState,
onReportChange: (String) -> Unit,
onCallsignChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onRequestSubmitReport: () -> Unit
) {
val options = remember(statusName) {
if (statusName.startsWith("ISS")) UPLOAD_OPTIONS else UPLOAD_OPTIONS.filter { it.apiValue != AMSAT_REPORT_CREW_ACTIVE }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = stringResource(R.string.amsat_upload_report),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.amsat_upload_time_now, formatUtcNowForUpload(System.currentTimeMillis())),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.amsat_upload_status),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
options.forEach { option ->
val selected = upload.selectedReport == option.apiValue
FilterChip(
selected = selected,
onClick = { onReportChange(option.apiValue) },
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (selected) Color.White else option.color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(stringResource(option.labelResId), maxLines = 1)
}
},
colors = FilterChipDefaults.filterChipColors(
containerColor = option.color.copy(alpha = 0.10f),
labelColor = MaterialTheme.colorScheme.onSurface,
selectedContainerColor = option.color,
selectedLabelColor = Color.White
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = option.color.copy(alpha = 0.60f),
selectedBorderColor = option.color
)
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = upload.callsign,
onValueChange = onCallsignChange,
label = { Text(stringResource(R.string.amsat_upload_callsign)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = upload.gridSquare,
onValueChange = onGridChange,
label = { Text(stringResource(R.string.amsat_upload_grid)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Button(
onClick = onRequestSubmitReport,
enabled = !upload.isSubmitting && upload.callsign.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
if (upload.isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text(stringResource(R.string.amsat_upload_submit))
}
}
upload.message?.let { message ->
if (message == UPLOAD_MESSAGE_SUCCESS) {
Text(
text = stringResource(R.string.amsat_upload_success),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
upload.error?.let { error ->
Text(
text = uploadErrorText(error),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun AmSatUploadConfirmDialog(
statusName: String,
upload: AmSatUploadUiState,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val option = UPLOAD_OPTIONS.firstOrNull { it.apiValue == upload.selectedReport } ?: UPLOAD_OPTIONS.first()
val statusLabel = stringResource(option.labelResId)
val grid = upload.gridSquare.ifBlank { stringResource(R.string.amsat_upload_grid_none) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.amsat_upload_confirm_title)) },
text = {
Text(
text = stringResource(
R.string.amsat_upload_confirm_message,
statusName,
statusLabel,
upload.callsign,
grid
)
)
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.amsat_upload_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss, enabled = !upload.isSubmitting) {
Text(stringResource(R.string.btn_cancel))
}
}
)
}
@Composable
private fun uploadErrorText(error: String): String {
return when (error) {
UPLOAD_ERROR_CALLSIGN_REQUIRED -> stringResource(R.string.amsat_upload_callsign_required)
UPLOAD_ERROR_GRID_INVALID -> stringResource(R.string.amsat_upload_grid_invalid)
else -> stringResource(R.string.amsat_upload_failed, error)
}
}
private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") private val MONTH_ABBR = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
private fun formatFetchedAt(utcMs: Long): String { private fun formatFetchedAt(utcMs: Long): String {
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.timeInMillis = utcMs cal.timeInMillis = utcMs
val day = cal.get(Calendar.DAY_OF_MONTH) val day = cal.get(Calendar.DAY_OF_MONTH)
val month = MONTH_ABBR[cal.get(Calendar.MONTH)] val monthIndex = cal.get(Calendar.MONTH)
val year = cal.get(Calendar.YEAR) val year = cal.get(Calendar.YEAR)
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0') val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0') val ss = cal.get(Calendar.SECOND).toString().padStart(2, '0')
return "$day$month $year - $hh:$mm:$ss" return if (Locale.getDefault().language == Locale.CHINESE.language) {
"${year}年${monthIndex + 1}月${day}日 - $hh:$mm:$ss"
} else {
"$day${MONTH_ABBR[monthIndex]} $year - $hh:$mm:$ss"
}
}
private fun formatUtcNowForUpload(utcMs: Long): String {
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
return formatter.format(Date(utcMs))
} }
@@ -4,14 +4,38 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
import com.rtbishop.look4sat.core.domain.model.SatReport import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatStatus import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
const val AMSAT_REPORT_HEARD = "Heard"
const val AMSAT_REPORT_TELEMETRY_ONLY = "Telemetry Only"
const val AMSAT_REPORT_NOT_HEARD = "Not Heard"
const val AMSAT_REPORT_CREW_ACTIVE = "Crew Active"
const val UPLOAD_ERROR_CALLSIGN_REQUIRED = "callsign_required"
const val UPLOAD_ERROR_GRID_INVALID = "grid_invalid"
const val UPLOAD_MESSAGE_SUCCESS = "success"
data class AmSatUploadUiState(
val isExpanded: Boolean = false,
val selectedReport: String = AMSAT_REPORT_HEARD,
val callsign: String = "",
val gridSquare: String = "",
val isSubmitting: Boolean = false,
val isConfirmingSubmit: Boolean = false,
val message: String? = null,
val error: String? = null
)
data class SatStatusUiState( data class SatStatusUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -19,11 +43,13 @@ data class SatStatusUiState(
val statuses: List<SatStatus> = emptyList(), val statuses: List<SatStatus> = emptyList(),
val reports: Map<String, SatReport> = emptyMap(), val reports: Map<String, SatReport> = emptyMap(),
val fetchedAtUtcMs: Long = 0L, val fetchedAtUtcMs: Long = 0L,
val error: String? = null val error: String? = null,
val upload: AmSatUploadUiState = AmSatUploadUiState()
) )
class SatStatusViewModel( class SatStatusViewModel(
private val amSatRepo: IAmSatRepository private val amSatRepo: IAmSatRepository,
private val settingsRepo: ISettingsRepo
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true)) private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
@@ -39,15 +65,7 @@ class SatStatusViewModel(
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { applyStatusPage(page)
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else { } else {
_uiState.update { _uiState.update {
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed") it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
@@ -68,14 +86,7 @@ class SatStatusViewModel(
try { try {
val page = amSatRepo.fetchStatus() val page = amSatRepo.fetchStatus()
if (page != null && page.statuses.isNotEmpty()) { if (page != null && page.statuses.isNotEmpty()) {
_uiState.update { applyStatusPage(page)
it.copy(
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs
)
}
} else { } else {
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") } _uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
} }
@@ -85,11 +96,196 @@ class SatStatusViewModel(
} }
} }
private fun applyStatusPage(page: SatStatusPage) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
statuses = page.statuses,
reports = page.reports,
fetchedAtUtcMs = page.fetchedAtUtcMs,
error = null
)
}
}
fun toggleUploadPanel() {
val storedCallsign = settingsRepo.getAmSatCallsign()
val defaultGrid = defaultGridSquare()
_uiState.update { state ->
val upload = state.upload
val expanded = !upload.isExpanded
state.copy(
upload = if (expanded) {
upload.copy(
isExpanded = true,
callsign = upload.callsign.ifBlank { storedCallsign },
gridSquare = upload.gridSquare.ifBlank { defaultGrid },
message = null,
error = null
)
} else {
upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null)
}
)
}
}
fun collapseUploadPanel() {
_uiState.update {
it.copy(upload = it.upload.copy(isExpanded = false, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadReport(report: String) {
_uiState.update {
it.copy(upload = it.upload.copy(selectedReport = report, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadCallsign(callsign: String) {
val normalized = callsign.trim().uppercase(Locale.US)
settingsRepo.setAmSatCallsign(normalized)
_uiState.update {
it.copy(upload = it.upload.copy(callsign = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun setUploadGrid(grid: String) {
val normalized = grid.trim().uppercase(Locale.US)
_uiState.update {
it.copy(upload = it.upload.copy(gridSquare = normalized, isConfirmingSubmit = false, message = null, error = null))
}
}
fun requestSubmitConfirmation() {
val prepared = validateUpload(_uiState.value.upload) ?: return
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isConfirmingSubmit = true,
message = null,
error = null
)
)
}
}
fun dismissSubmitConfirmation() {
_uiState.update { it.copy(upload = it.upload.copy(isConfirmingSubmit = false)) }
}
fun confirmSubmitReport(satelliteName: String) {
val upload = _uiState.value.upload
if (upload.isSubmitting) return
val prepared = validateUpload(upload) ?: return
settingsRepo.setAmSatCallsign(prepared.callsign)
_uiState.update {
it.copy(
upload = it.upload.copy(
callsign = prepared.callsign,
gridSquare = prepared.grid,
isSubmitting = true,
isConfirmingSubmit = false,
error = null,
message = null
)
)
}
viewModelScope.launch {
val result = amSatRepo.submitReport(
AmSatReportSubmission(
name = satelliteName,
report = prepared.report,
callsign = prepared.callsign,
gridSquare = prepared.grid,
reportedAtUtcMillis = System.currentTimeMillis()
)
)
if (result.success) {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
callsign = prepared.callsign,
gridSquare = prepared.grid,
message = UPLOAD_MESSAGE_SUCCESS,
error = null
)
)
}
refresh()
} else {
_uiState.update {
it.copy(
upload = it.upload.copy(
isSubmitting = false,
message = null,
error = result.message.ifBlank { "Upload failed" }
)
)
}
}
}
}
private fun validateUpload(upload: AmSatUploadUiState): PreparedUpload? {
val callsign = upload.callsign.trim().uppercase(Locale.US)
val grid = upload.gridSquare.trim().uppercase(Locale.US)
return when {
callsign.isBlank() -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_CALLSIGN_REQUIRED,
message = null
)
)
}
null
}
grid.isNotBlank() && !MAIDENHEAD_GRID.matches(grid) -> {
_uiState.update {
it.copy(
upload = it.upload.copy(
isConfirmingSubmit = false,
error = UPLOAD_ERROR_GRID_INVALID,
message = null
)
)
}
null
}
else -> PreparedUpload(
report = upload.selectedReport,
callsign = callsign,
grid = grid
)
}
}
private fun defaultGridSquare(): String {
val grid = settingsRepo.stationPosition.value.qthLocator.trim().uppercase(Locale.US)
return grid.takeUnless { it == "JJ00AA" }.orEmpty()
}
companion object { companion object {
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
SatStatusViewModel(container.amSatRepo) SatStatusViewModel(container.amSatRepo, container.settingsRepo)
} }
} }
} }
} }
private data class PreparedUpload(
val report: String,
val callsign: String,
val grid: String
)
private val MAIDENHEAD_GRID = Regex("^[A-R]{2}[0-9]{2}([A-X]{2})?$")
+14 -10
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "446" appVersionCode = "448"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6" appVersionName = "4.4.8"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -12,24 +12,25 @@ jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.3.1" android-gradle-plugin = "9.4.1"
androidx-core-ktx = "1.19.0" androidx-core-ktx = "1.19.1"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4" androidx-room = "2.8.5"
compose-bom = "2026.06.01" compose-bom = "2026.09.00"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.11.0" compose-lifecycle = "2.11.0"
compose-navigation3 = "1.1.5" compose-navigation3 = "1.2.0"
google-ksp = "2.3.10" google-ksp = "2.3.11"
kotlin = "2.4.10" kotlin = "2.4.20"
kotlin-coroutines = "1.11.0" kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0" kotlin-serialization = "1.11.0"
other-okhttp = "5.4.0" other-detekt = "1.23.8"
other-okhttp = "5.5.0"
other-osmdroid = "6.1.20" other-osmdroid = "6.1.20"
test-junit4 = "4.13.2" test-junit4 = "4.13.2"
@@ -72,6 +73,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
#noinspection UnusedVersionCatalogEntry
other-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "other-detekt" }
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" } other-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "other-okhttp" }
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -90,6 +93,7 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" } google-ksp = { id = "com.google.devtools.ksp", version.ref = "google-ksp" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
other-detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "other-detekt" }
# plugins defined by this project # plugins defined by this project
convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" } convention-applicationPlugin = { id = "com.rtbishop.look4sat.convention.applicationPlugin" }
convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" } convention-coreDataPlugin = { id = "com.rtbishop.look4sat.convention.coreDataPlugin" }
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Aug 11 14:16:53 CEST 2026 #Sun Sep 27 11:54:01 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME