Compare commits

..
8 Commits
32 changed files with 427 additions and 355 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+39 -35
View File
@@ -25,50 +25,54 @@ jobs:
java-version: '17' java-version: '17'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v5 uses: gradle/actions/setup-gradle@v6
- name: Assemble Artifacts - name: Assemble Artifacts
run: | run: ./gradlew assembleRelease bundleRelease
./gradlew assembleRelease
./gradlew bundleRelease
- name: Sign APK - name: Sign APK
uses: r0adkll/sign-android-release@v1 run: |
id: sign_apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
with: APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
releaseDirectory: app/build/outputs/apk/release BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
signingKeyBase64: ${{ secrets.KEY_STORE }} ${BUILD_TOOLS}apksigner sign \
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} --ks keystore.jks \
alias: ${{ secrets.KEY_ALIAS }} --ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
keyPassword: ${{ secrets.KEY_PASSWORD }} --ks-key-alias ${{ secrets.KEY_ALIAS }} \
env: --key-pass pass:${{ secrets.KEY_PASSWORD }} \
BUILD_TOOLS_VERSION: "36.0.0" --out app/build/outputs/apk/release/look4sat.apk \
"$APK"
rm keystore.jks
- name: Sign Bundle - name: Sign Bundle
uses: r0adkll/sign-android-release@v1
id: sign_bundle
with:
releaseDirectory: app/build/outputs/bundle/release
signingKeyBase64: ${{ secrets.KEY_STORE }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
alias: ${{ secrets.KEY_ALIAS }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
env:
BUILD_TOOLS_VERSION: "36.0.0"
- name: Rename Artifacts
run: | run: |
mv ${{steps.sign_apk.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.apk echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
mv ${{steps.sign_bundle.outputs.signedReleaseFile}} app/build/outputs/apk/release/look4sat.aab AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Deploy Bundle - name: Setup Ruby
uses: r0adkll/upload-google-play@v1 uses: ruby/setup-ruby@v1
with: with:
serviceAccountJsonPlainText: ${{secrets.SERVICE_ACCOUNT_JSON}} ruby-version: '3.3'
packageName: com.rtbishop.look4sat
releaseFiles: app/build/outputs/apk/release/look4sat.aab - name: Deploy Bundle to Google Play
track: production run: |
whatsNewDirectory: fastlane/metadata/android/en-US/whatsnew gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release - name: Create Release
run: | run: |
+3 -1
View File
@@ -14,9 +14,11 @@
<application <application
android:name=".MainApplication" android:name=".MainApplication"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -76,11 +76,11 @@ import com.rtbishop.look4sat.feature.settings.SettingsDestination
fun MainScreen() { fun MainScreen() {
val backStack = rememberNavBackStack(Screen.Passes) val backStack = rememberNavBackStack(Screen.Passes)
val currentKey = backStack.lastOrNull() val currentKey = backStack.lastOrNull()
val navigateBack: () -> Unit = { backStack.removeAt(backStack.size - 1) } val navigateBack: () -> Unit = { if (backStack.size > 1) backStack.removeAt(backStack.size - 1) }
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350)) val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
// val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f) // val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
// val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it }) // val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar(), Screen.Map, Screen.Settings) val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Map, Screen.Settings)
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
@@ -138,25 +138,15 @@ fun MainScreen() {
} }
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
backStack.add(Screen.Radar(catNum, aosTime)) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar)
} }
} }
entry<Screen.Radar> { route -> entry<Screen.Radar> {
RadarDestination( RadarDestination(navigateToRadioControl = { backStack.add(Screen.RadioControl) })
catNum = route.catNum,
aosTime = route.aosTime,
navigateUp = navigateBack,
navigateToRadioControl = { catNum, aosTime ->
backStack.add(Screen.RadioControl(catNum, aosTime))
}
)
} }
entry<Screen.RadioControl> { route -> entry<Screen.RadioControl> {
RadioControlDestination( RadioControlDestination(navigateUp = navigateBack)
catNum = route.catNum,
aosTime = route.aosTime,
navigateUp = navigateBack
)
} }
entry<Screen.Map> { entry<Screen.Map> {
MapDestination() MapDestination()
@@ -184,7 +174,8 @@ fun MainScreen() {
.clickable { .clickable {
val pass = trackingState.currentPass val pass = trackingState.currentPass
if (pass != null) { if (pass != null) {
backStack.add(Screen.RadioControl(pass.catNum, pass.aosTime)) container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.RadioControl)
} }
} }
.padding(horizontal = 12.dp, vertical = 6.dp) .padding(horizontal = 12.dp, vertical = 6.dp)
@@ -40,28 +40,22 @@ class DatabaseRepo(
private val settingsRepo: ISettingsRepo private val settingsRepo: ISettingsRepo
) : IDatabaseRepo { ) : IDatabaseRepo {
private companion object { private val customSourceType = "Other"
val tleTypes = setOf("Amsat", "R4UAB", "Other")
val zippedTleTypes = setOf("McCants", "Classified")
}
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
remoteSource.getFileStream(uri)?.let { stream -> remoteSource.getFileStream(uri)?.let { stream ->
val entries = dataParser.parseTLEStream(stream) val entries = dataParser.parseTLEStream(unwrapIfZipped(uri, stream))
localSource.insertEntries(entries) localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds("Other", entries.map { it.catnum }) settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
} }
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) { override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
remoteSource.getFileStream(uri) remoteSource.getFileStream(uri)?.let { stream ->
?.let { dataParser.parseJSONStream(it) } val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
?.takeIf { it.isNotEmpty() } localSource.insertRadios(transceivers)
?.let { }
localSource.deleteRadios()
localSource.insertRadios(it)
}
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
} }
@@ -69,28 +63,26 @@ class DatabaseRepo(
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
val tleUrls = buildMap { val tleUrls = buildMap {
putAll(Sources.satelliteDataUrls) putAll(Sources.satelliteDataUrls)
if (dataSourcesSettings.useCustomTLE) put("Other", dataSourcesSettings.tleUrl) if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
}.filterValues { it.isNotEmpty() } }.filterValues { it.isNotBlank() }
val radioUrls = buildMap {
val radioUrls = buildList { putAll(Sources.transceiversDataUrls)
add(Sources.RADIO_DATA_URL) if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
if (dataSourcesSettings.useCustomTransceivers) add(dataSourcesSettings.transceiversUrl) }.filterValues { it.isNotBlank() }
}
// launch all network requests concurrently // launch all network requests concurrently
val tleJobs = tleUrls.map { (type, url) -> async { type to remoteSource.getNetworkStream(url) } } val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.map { url -> async { remoteSource.getNetworkStream(url) } } val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
// parse fetched data concurrently and associate with types
// parse satellite data val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
val importedEntries = tleJobs.awaitAll().flatMap { (type, stream) -> val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(type, it) }.orEmpty().also { satellites -> stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, satellites.map { it.catnum }) settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
} }
} }
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
// parse radio data stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
val importedRadios = radioJobs.awaitAll().filterNotNull().flatMap { dataParser.parseJSONStream(it) } }
// insert parsed data into the database
localSource.insertEntries(importedEntries) localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios) localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis()) setUpdateSuccessful(System.currentTimeMillis())
@@ -102,10 +94,9 @@ class DatabaseRepo(
setUpdateSuccessful(0L) setUpdateSuccessful(0L)
} }
private suspend fun parseSatelliteStream(type: String, stream: InputStream): List<OrbitalData> = when (type) { private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> = when {
in tleTypes -> dataParser.parseTLEStream(stream) url.contains("FORMAT=csv", ignoreCase = true) -> dataParser.parseCSVStream(stream)
in zippedTleTypes -> dataParser.parseTLEStream(ZipInputStream(stream).apply { nextEntry }) else -> dataParser.parseTLEStream(stream)
else -> dataParser.parseCSVStream(stream)
} }
private suspend fun setUpdateSuccessful(timestamp: Long) { private suspend fun setUpdateSuccessful(timestamp: Long) {
@@ -113,4 +104,7 @@ class DatabaseRepo(
DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp) DatabaseState(localSource.getRadiosTotal(), localSource.getEntriesTotal(), timestamp)
) )
} }
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
} }
@@ -52,6 +52,13 @@ class SatelliteRepo(
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList()) private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites override val satellites: StateFlow<List<OrbitalObject>> = _satellites
private val _selectedPass = MutableStateFlow(0 to 0L)
override val selectedPass: StateFlow<Pair<Int, Long>> = _selectedPass
override fun selectPass(catNum: Int, aosTime: Long) {
_selectedPass.value = catNum to aosTime
}
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id) override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) { override suspend fun initRepository() = withContext(dispatcher) {
@@ -242,7 +242,8 @@ object CelestialComputer {
t = (jd - 2451545.0) / 36525.0 t = (jd - 2451545.0) / 36525.0
var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t var teg = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + (0.000387933 * t - t * t / 38710000.0) * t
while (teg > 360.0) teg -= 360.0 while (teg > 360.0) teg -= 360.0
val th = fixAngle((teg - observer.longitude) * DEG2RAD) // LST = GMST + east longitude (positive east convention)
val th = mod2PI((teg + observer.longitude) * DEG2RAD)
val h = th - ra val h = th - ra
val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI val azVal = atan2(sin(h), cos(h) * sin(n) - tan(dec) * cos(n)) + PI
val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h)) val el = asin(sin(n) * sin(dec) + cos(n) * cos(dec) * cos(h))
@@ -563,9 +564,9 @@ object CelestialComputer {
// ── Internal helpers ── // ── Internal helpers ──
private fun observerGeodetic(pos: GeoPos): DoubleArray { private fun observerGeodetic(pos: GeoPos): DoubleArray {
// [lat_rad, lon_rad, alt_km] — longitude negated so that // [lat_rad, lon_rad, alt_km] — longitude positive east, matching OrbitalObject convention.
// mod2PI(thetaGJD + obsGeo[1]) == mod2PI(thetaGJD + lon_rad) // LST = thetaGJD(julUtc) + obsGeo[1] = GMST + lon_rad (correct).
return doubleArrayOf(pos.latitude * DEG2RAD, -pos.longitude * DEG2RAD, pos.altitude / 1000.0) return doubleArrayOf(pos.latitude * DEG2RAD, pos.longitude * DEG2RAD, pos.altitude / 1000.0)
} }
/** /**
@@ -34,6 +34,12 @@ interface ISatelliteRepo {
/** Whether the repo is currently calculating passes. */ /** Whether the repo is currently calculating passes. */
val isCalculating: StateFlow<Boolean> val isCalculating: StateFlow<Boolean>
/** Currently selected pass (catNum + aosTime), persisted across screen navigations. */
val selectedPass: StateFlow<Pair<Int, Long>>
/** Set the currently selected pass. */
fun selectPass(catNum: Int, aosTime: Long)
/** Load satellite objects from DB based on the current selection. */ /** Load satellite objects from DB based on the current selection. */
suspend fun initRepository() suspend fun initRepository()
@@ -18,7 +18,6 @@
package com.rtbishop.look4sat.core.domain.source package com.rtbishop.look4sat.core.domain.source
object Sources { object Sources {
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf( val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv", "All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv", "Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
@@ -49,4 +48,7 @@ object Sources {
"R4UAB" to "https://r4uab.ru/satonline.txt", "R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter "Other" to "" // key for sats filter
) )
val transceiversDataUrls = mapOf(
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
)
} }
@@ -58,8 +58,6 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
} }
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
private fun parseCSV(values: List<String>): OrbitalData? = runCatching { private fun parseCSV(values: List<String>): OrbitalData? = runCatching {
val name = values[0] val name = values[0]
val timestamp = values[2] val timestamp = values[2]
@@ -106,7 +104,8 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
}.onFailure { println("TLE parsing exception: $it") }.getOrNull() }.onFailure { println("TLE parsing exception: $it") }.getOrNull()
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int { private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
val daysInMonth = intArrayOf(31, if (isLeapYear(year)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val isLeapYear = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
val daysInMonth = intArrayOf(31, if (isLeapYear) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
return daysInMonth.take(month - 1).sum() + dayOfMonth return daysInMonth.take(month - 1).sum() + dayOfMonth
} }
} }
@@ -95,11 +95,11 @@ class DataParserTest {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream)) assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
} }
@Test // @Test
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) { // fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048) // val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
val answers = listOf(false, true, false, true, false, true, false, true) // val answers = listOf(false, true, false, true, false, true, false, true)
val results = years.map { dataParser.isLeapYear(it) } // val results = years.map { dataParser.isLeapYear(it) }
assert(results == answers) // assert(results == answers)
} // }
} }
@@ -178,12 +178,6 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
painter = painterResource(R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp) Text(text = "${pass.altitude} km", fontSize = 15.sp)
} }
Text( Text(
@@ -30,10 +30,10 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass) data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
@Serializable @Serializable
data class Radar(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(R.drawable.ic_radar, R.string.nav_radar) data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
@Serializable @Serializable
data class RadioControl(val catNum: Int = 0, val aosTime: Long = 0L) : Screen(0, 0) data object RadioControl : Screen(0, 0)
@Serializable @Serializable
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@@ -0,0 +1,105 @@
package com.rtbishop.look4sat.core.presentation
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
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.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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 kotlinx.coroutines.launch
@Composable
fun SwipeableItem(onSwipeRight: () -> Unit, onSwipeLeft: () -> Unit, content: @Composable () -> Unit) {
val coroutineScope = rememberCoroutineScope()
val dismissThresholdPx = with(LocalDensity.current) { 120.dp.toPx() }
val dismissState = rememberSwipeToDismissBoxState { dismissThresholdPx }
val willTrigger by remember { derivedStateOf { dismissState.targetValue != SwipeToDismissBoxValue.Settled } }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(willTrigger) {
val feedbackType = if (willTrigger) HapticFeedbackType.LongPress else HapticFeedbackType.SegmentTick
if (willTrigger) hapticFeedback.performHapticFeedback(feedbackType)
}
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = true,
enableDismissFromEndToStart = true,
backgroundContent = {
val isSwipeRight = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd
SwipeBackground(isSwipeRight, willTrigger, MaterialTheme.colorScheme.primary)
},
content = { content() },
onDismiss = {
val targetValue = dismissState.targetValue
when (targetValue) {
SwipeToDismissBoxValue.StartToEnd -> onSwipeRight()
SwipeToDismissBoxValue.EndToStart -> onSwipeLeft()
SwipeToDismissBoxValue.Settled -> {}
}
if (targetValue != SwipeToDismissBoxValue.Settled) {
coroutineScope.launch { dismissState.snapTo(SwipeToDismissBoxValue.Settled) }
}
}
)
}
@Composable
fun SwipeBackground(isSwipeRight: Boolean, willTrigger: Boolean, bgColor: Color = Color(0x00000000)) {
val shape = RectangleShape
Box(
contentAlignment = if (isSwipeRight) Alignment.CenterStart else Alignment.CenterEnd,
modifier = Modifier
.fillMaxSize()
.border(width = 1.dp, shape = shape, color = bgColor)
.dropShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) .2f else 0f
}
.innerShadow(shape = shape) {
color = bgColor
radius = 40f
alpha = if (willTrigger) 1f else .2f
}
) {
val iconScale by animateFloatAsState(targetValue = if (willTrigger) 1f else .8f)
val slide by animateDpAsState(targetValue = if (willTrigger) 32.dp else (12).dp)
Icon(
painter = painterResource(R.drawable.ic_filter),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.fillMaxHeight()
.rotate(if (isSwipeRight) 0f else 180f)
.graphicsLayer {
scaleX = iconScale
scaleY = iconScale
translationX = slide.toPx()
}
)
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
</vector>
@@ -130,8 +130,16 @@
<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_outro_title">我要感谢:</string> <string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat 所有用户及贡献者!
\n• David A. B. Johnson (predict4java)
\n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources> </resources>
@@ -49,12 +49,9 @@
\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 fixes for Turkish translation, by Emre Can Akdaş (TA3ECR) * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
\n* Added passes sticky header with sunrise/sunset times \n\n* Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has
\n* Added current moon/sun positions to the Radar Screen a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
\n* Added current moon/sun positions to the Map Screen
\n* Added red night mode filter overlay for the whole app
</string> </string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -1,5 +1,2 @@
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR) * Implemented Swipe-to-Focus behavior for passes - swipe right on a pass to focus it, and left - to unfocus
* Added passes sticky header with sunrise/sunset times * Implemented preserving the pass selection between the Radar and Map screens. Not every satellite has a pass overhead every day, so this should make it only slightly easier to keep track of certain satellites.
* Added current moon/sun positions to the Radar Screen
* Added current moon/sun positions to the Map Screen
* Added red night mode filter overlay for the whole app
@@ -1,5 +0,0 @@
* Added fixes for Turkish translation, by Emre Can Akdaş (TA3ECR)
* Added passes sticky header with sunrise/sunset times
* Added current moon/sun positions to the Radar Screen
* Added current moon/sun positions to the Map Screen
* Added red night mode filter overlay for the whole app
@@ -123,10 +123,7 @@ private val moonIconPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
fun MapDestination() { fun MapDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
modelClass = MapViewModel::class.java,
factory = MapViewModel.factory(container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
@@ -76,7 +76,8 @@ class MapViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc) } _uiState.update { it.copy(isUtc = settings.stateOfUtc) }
} }
} }
selectDefaultSatellite(-1) val (selectedCatNum, _) = satelliteRepo.selectedPass.value
selectDefaultSatellite(if (selectedCatNum != 0) selectedCatNum else -1)
} }
fun onAction(action: MapAction) { fun onAction(action: MapAction) {
@@ -126,10 +127,16 @@ class MapViewModel(
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
// Scale update interval based on satellite count to avoid excessive CPU usage
val effectiveRate = when {
allSatellites.size > 5000 -> maxOf(updateFreq, 3000L)
allSatellites.size > 1000 -> maxOf(updateFreq, 2000L)
else -> updateFreq
}
while (isActive) { while (isActive) {
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(updateFreq) delay(effectiveRate)
} }
} }
} }
@@ -17,6 +17,11 @@
*/ */
package com.rtbishop.look4sat.feature.passes package com.rtbishop.look4sat.feature.passes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -29,11 +34,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -45,8 +47,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -72,6 +72,7 @@ import com.rtbishop.look4sat.core.presentation.MainTheme
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.presentation.ScreenColumn import com.rtbishop.look4sat.core.presentation.ScreenColumn
import com.rtbishop.look4sat.core.presentation.SwipeableItem
import com.rtbishop.look4sat.core.presentation.TimerRow 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.infiniteMarquee import com.rtbishop.look4sat.core.presentation.infiniteMarquee
@@ -85,10 +86,7 @@ import java.util.TimeZone
fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) { fun PassesDestination(navigateToRadar: (Int, Long) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: PassesViewModel = viewModel(factory = PassesViewModel.factory(container))
modelClass = PassesViewModel::class.java,
factory = PassesViewModel.factory(container)
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
PassesScreen(uiState, viewModel::onAction, navigateToRadar) PassesScreen(uiState, viewModel::onAction, navigateToRadar)
} }
@@ -125,7 +123,6 @@ private fun PassesScreen(
onAction(PassesAction.DismissWhatsNew) onAction(PassesAction.DismissWhatsNew)
} }
} }
val gridState = rememberLazyGridState()
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
TopBar( TopBar(
@@ -149,10 +146,11 @@ private fun PassesScreen(
isRefreshing = uiState.isRefreshing, isRefreshing = uiState.isRefreshing,
isUtc = uiState.isUtc, isUtc = uiState.isUtc,
passes = uiState.itemsList, passes = uiState.itemsList,
groupedPasses = uiState.groupedPasses,
sunTimes = uiState.sunTimes,
focusedCatNum = uiState.focusedCatNum,
navigateToRadar = navigateToRadar, navigateToRadar = navigateToRadar,
refreshPasses = { onAction(PassesAction.RefreshPasses) }, onAction = onAction
gridState = gridState,
sunTimes = uiState.sunTimes
) )
} }
} }
@@ -163,50 +161,19 @@ private fun PassesList(
isRefreshing: Boolean, isRefreshing: Boolean,
isUtc: Boolean, isUtc: Boolean,
passes: List<OrbitalPass>, passes: List<OrbitalPass>,
groupedPasses: Map<String, List<OrbitalPass>>,
sunTimes: Map<String, Pair<String, String>>,
focusedCatNum: Int?,
navigateToRadar: (Int, Long) -> Unit, navigateToRadar: (Int, Long) -> Unit,
refreshPasses: () -> Unit, onAction: (PassesAction) -> Unit
gridState: LazyGridState,
sunTimes: Map<String, Pair<String, String>>
) { ) {
val isVerticalLayout = isVerticalLayout() val isVerticalLayout = isVerticalLayout()
val refreshState = rememberPullToRefreshState() val refreshState = rememberPullToRefreshState()
val timeZone = remember(isUtc) { if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() }
val sdfDate = remember(isUtc) {
SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = timeZone }
}
// Group passes by calendar day so we know where headers go
val groupedPasses = remember(passes, isUtc) {
passes.filter { !it.isDeepSpace }.groupBy { sdfDate.format(Date(it.aosTime)) } +
if (passes.any { it.isDeepSpace }) mapOf("Deep Space" to passes.filter { it.isDeepSpace }) else emptyMap()
}
// Derive the sticky header label from the first visible item index
val stickyHeader by remember(gridState, groupedPasses) {
derivedStateOf {
val firstVisible = gridState.firstVisibleItemIndex
var itemIndex = 0
var header = groupedPasses.keys.firstOrNull() ?: ""
for ((key, items) in groupedPasses) {
header = key
itemIndex += 1 + items.size
if (itemIndex > firstVisible) break
}
header
}
}
// The sticky overlay should only show when the first in-list header has scrolled out of view.
// firstVisibleItemIndex == 0 means the first header is still visible — hide the overlay.
val showStickyOverlay by remember(gridState) {
derivedStateOf { gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 }
}
ElevatedCard(modifier = Modifier.fillMaxSize()) { ElevatedCard(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
state = refreshState, state = refreshState,
onRefresh = refreshPasses, onRefresh = { onAction(PassesAction.RefreshPasses) },
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = refreshState, state = refreshState,
@@ -220,31 +187,35 @@ private fun PassesList(
if (passes.isEmpty()) { if (passes.isEmpty()) {
EmptyListCard(message = stringResource(R.string.pass_empty_list_message)) EmptyListCard(message = stringResource(R.string.pass_empty_list_message))
} else { } else {
Column { AnimatedContent(
// Sticky header overlay — only visible once the in-list header scrolls away targetState = focusedCatNum,
if (showStickyOverlay) { transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
val (rise, set) = sunTimes[stickyHeader] ?: ("--:--" to "--:--") label = "PassesFocusTransition"
StickyDateHeader(label = stickyHeader, sunriseTime = rise, sunsetTime = set) ) { targetFocus ->
} LazyVerticalGrid(columns = GridCells.Adaptive(320.dp), modifier = Modifier.fillMaxSize()) {
LazyVerticalGrid(
state = gridState,
columns = GridCells.Adaptive(320.dp),
modifier = Modifier.fillMaxSize()
) {
for ((dateLabel, dayPasses) in groupedPasses) { for ((dateLabel, dayPasses) in groupedPasses) {
// In-list section header (scrolls away, drives the sticky overlay) val headerVisible = targetFocus == null || dayPasses.any { it.catNum == targetFocus }
item(span = { GridItemSpan(maxLineSpan) }) { if (headerVisible) {
val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--") stickyHeader(key = "header_$dateLabel") {
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set) val (rise, set) = sunTimes[dateLabel] ?: ("--:--" to "--:--")
StickyDateHeader(label = dateLabel, sunriseTime = rise, sunsetTime = set)
}
} }
items(items = dayPasses, key = { item -> item.catNum + item.aosTime }) { pass -> val visiblePasses = if (targetFocus == null) dayPasses
PassItem( else dayPasses.filter { it.catNum == targetFocus }
pass = pass, items(items = visiblePasses, key = { item -> item.catNum + item.aosTime }) { pass ->
navigateToRadar = navigateToRadar, SwipeableItem(
modifier = Modifier.animateItem(), onSwipeRight = { onAction(PassesAction.FocusCatNum(pass.catNum)) },
isVerticalLayout = isVerticalLayout, onSwipeLeft = { onAction(PassesAction.ClearFocus) }
isUtc = isUtc ) {
) PassItem(
pass = pass,
navigateToRadar = navigateToRadar,
modifier = Modifier.animateItem(),
isVerticalLayout = isVerticalLayout,
isUtc = isUtc
)
}
} }
} }
} }
@@ -397,13 +368,6 @@ private fun PassItem(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
painter = painterResource(id = R.drawable.ic_altitude),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface) Text(text = "${pass.altitude} km", fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
} }
Row( Row(
@@ -32,9 +32,10 @@ data class PassesState(
val showDeepSpace: Boolean = true, val showDeepSpace: Boolean = true,
val modes: List<String> = emptyList(), val modes: List<String> = emptyList(),
val itemsList: List<OrbitalPass> = emptyList(), val itemsList: List<OrbitalPass> = emptyList(),
val groupedPasses: Map<String, List<OrbitalPass>> = emptyMap(),
val shouldSeeWhatsNew: Boolean = false, val shouldSeeWhatsNew: Boolean = false,
// Map of dateLabel -> Pair(sunriseTime, sunsetTime) for each day group val sunTimes: Map<String, Pair<String, String>> = emptyMap(),
val sunTimes: Map<String, Pair<String, String>> = emptyMap() val focusedCatNum: Int? = null
) )
sealed interface PassesAction { sealed interface PassesAction {
@@ -44,4 +45,6 @@ sealed interface PassesAction {
data object RefreshPasses : PassesAction data object RefreshPasses : PassesAction
data object TogglePassesDialog : PassesAction data object TogglePassesDialog : PassesAction
data object ToggleRadiosDialog : PassesAction data object ToggleRadiosDialog : PassesAction
data class FocusCatNum(val catNum: Int) : PassesAction
data object ClearFocus : PassesAction
} }
@@ -74,37 +74,31 @@ class PassesViewModel(
_uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } _uiState.update { it.copy(isUtc = settings.stateOfUtc, shouldSeeWhatsNew = settings.shouldSeeWhatsNew) }
} }
} }
// Local tick loop — computes pass progress, countdown timer, and per-day sun times every second // Main tick loop: reacts to new passes, then ticks every second
viewModelScope.launch { viewModelScope.launch {
var lastSunTimesKey = "" // track when we need to recompute sun times satelliteRepo.passes.collectLatest { allPasses ->
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val isUtc = _uiState.value.isUtc val isUtc = _uiState.value.isUtc
val showDeepSpace = _uiState.value.showDeepSpace val showDeepSpace = _uiState.value.showDeepSpace
val allPasses = satelliteRepo.passes.value val filtered = allPasses
val filtered = if (showDeepSpace) allPasses else allPasses.filter { !it.isDeepSpace } .let { if (showDeepSpace) it else it.filter { pass -> !pass.isDeepSpace } }
val processed = computePassProgress(filtered, timeNow) val processed = computePassProgress(filtered, timeNow)
val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow) val (nextPass, nextTime, isAos) = resolveNextPass(processed, timeNow)
val sunTimes = computeSunTimes(processed, isUtc)
// Recompute per-day sun times only when passes list or UTC setting changes val grouped = groupPasses(processed, isUtc)
val sunTimesKey = "${processed.firstOrNull()?.aosTime}-${processed.lastOrNull()?.aosTime}-$isUtc" _uiState.update {
val sunTimes = if (sunTimesKey != lastSunTimesKey) { it.copy(
lastSunTimesKey = sunTimesKey itemsList = processed,
computeSunTimes(processed, isUtc) groupedPasses = grouped,
} else { sunTimes = sunTimes,
_uiState.value.sunTimes nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos
)
}
delay(1000)
} }
_uiState.update {
it.copy(
itemsList = processed,
nextPass = nextPass,
nextTime = nextTime,
isNextTimeAos = isAos,
sunTimes = sunTimes
)
}
delay(1000)
} }
} }
} }
@@ -121,16 +115,25 @@ class PassesViewModel(
_uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) } _uiState.update { it.copy(isPassesDialogShown = !it.isPassesDialogShown) }
PassesAction.ToggleRadiosDialog -> PassesAction.ToggleRadiosDialog ->
_uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) } _uiState.update { it.copy(isRadiosDialogShown = !it.isRadiosDialogShown) }
is PassesAction.FocusCatNum -> _uiState.update { it.copy(focusedCatNum = action.catNum) }
PassesAction.ClearFocus -> _uiState.update { it.copy(focusedCatNum = null) }
} }
} }
/** Computes sunrise/sunset strings for each unique calendar day in the pass list. */ // Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> { private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
val stationPos = settingsRepo.stationPosition.value val stationPos = settingsRepo.stationPosition.value
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault() val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz } val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz } val sdfTime = SimpleDateFormat("HH:mm", Locale.ENGLISH).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>() val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
if (passes.any { it.isDeepSpace }) {
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
}
for (pass in passes) { for (pass in passes) {
if (pass.isDeepSpace) continue if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime)) val label = sdfDate.format(Date(pass.aosTime))
@@ -143,6 +146,18 @@ class PassesViewModel(
return result return result
} }
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
val sdfDate = SimpleDateFormat("EEE, dd MMM yyyy", Locale.ENGLISH).also { it.timeZone = tz }
val ordered = LinkedHashMap<String, List<OrbitalPass>>()
val deepSpace = passes.filter { it.isDeepSpace }
if (deepSpace.isNotEmpty()) ordered["DeepSpace (period >225min)"] = deepSpace
passes.filter { !it.isDeepSpace }
.groupByTo(LinkedHashMap()) { sdfDate.format(Date(it.aosTime)) }
.forEach { (k, v) -> ordered[k] = v }
return ordered
}
/** Computes live progress for each pass, filtering out expired ones. */ /** Computes live progress for each pass, filtering out expired ones. */
private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> { private fun computePassProgress(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
val result = ArrayList<OrbitalPass>(passList.size) val result = ArrayList<OrbitalPass>(passList.size)
@@ -42,7 +42,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -77,42 +76,23 @@ import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
@Composable @Composable
fun RadarDestination( fun RadarDestination(navigateToRadioControl: () -> Unit = {}) {
catNum: Int = 0,
aosTime: Long = 0L,
navigateUp: () -> Unit,
navigateToRadioControl: (Int, Long) -> Unit = { _, _ -> }
) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
modelClass = RadarViewModel::class.java,
key = "$catNum-$aosTime",
factory = RadarViewModel.factory(catNum, aosTime, container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadarScreen(uiState, viewModel::onAction, navigateUp, navigateToRadioControl) RadarScreen(uiState, viewModel::onAction, navigateToRadioControl)
} }
@Composable @Composable
private fun RadarScreen( private fun RadarScreen(
uiState: RadarState, uiState: RadarState,
onAction: (RadarAction) -> Unit, onAction: (RadarAction) -> Unit,
navigateUp: () -> Unit, navigateToRadioControl: () -> Unit
navigateToRadioControl: (Int, Long) -> Unit
) { ) {
val upcomingPass = uiState.currentPass ?: getDefaultPass() val upcomingPass = uiState.currentPass ?: getDefaultPass()
LaunchedEffect(uiState.isLos) { if (uiState.isLos) navigateUp() }
val addToCalendar: () -> Unit = { val addToCalendar: () -> Unit = {
uiState.currentPass?.let { pass -> uiState.currentPass?.let { onAction(RadarAction.AddToCalendar(it.name, it.aosTime, it.losTime)) }
onAction(RadarAction.AddToCalendar(pass.name, pass.aosTime, pass.losTime))
}
}
val openRadioControl: () -> Unit = {
uiState.currentPass?.let { pass ->
navigateToRadioControl(pass.catNum, pass.aosTime)
}
} }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -125,7 +105,7 @@ private fun RadarScreen(
TopBar { TopBar {
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios) IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) } TopBar { NextPassRow(pass = upcomingPass, isUtc = uiState.isUtc) }
} else { } else {
@@ -133,7 +113,7 @@ private fun RadarScreen(
IconCard(action = addToCalendar, resId = R.drawable.ic_calendar) IconCard(action = addToCalendar, resId = R.drawable.ic_calendar)
TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos) TimerRow(timeString = uiState.currentTime, isTimeAos = uiState.isTimeAos)
NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc) NextPassRow(pass = upcomingPass, modifier = Modifier.weight(1f), isUtc = uiState.isUtc)
IconCard(action = openRadioControl, resId = R.drawable.ic_radios) IconCard(action = navigateToRadioControl, resId = R.drawable.ic_radios)
} }
} }
if (isVertical) { if (isVertical) {
@@ -43,8 +43,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RadarViewModel( class RadarViewModel(
private val catNum: Int,
private val aosTime: Long,
private val bluetoothReporter: IReporter, private val bluetoothReporter: IReporter,
private val networkReporter: IReporter, private val networkReporter: IReporter,
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -85,26 +83,24 @@ class RadarViewModel(
// Resolve which pass we're tracking and start the tick loop // Resolve which pass we're tracking and start the tick loop
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val currentPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } val (catNum, aosTime) = satelliteRepo.selectedPass.value
?: passes.firstOrNull() val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass?.let { satPass -> satPass?.let { pass ->
_uiState.update { it.copy(currentPass = satPass) } _uiState.update { it.copy(currentPass = pass) }
val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) val transmitters = satelliteRepo.getRadiosWithId(pass.catNum)
// Compute track once (it doesn't change for a given pass) // Compute track once (it doesn't change for a given pass)
if (!satPass.isDeepSpace) { if (!pass.isDeepSpace) {
val track = satelliteRepo.getTrack( val track = satelliteRepo.getTrack(pass.orbitalObject, stationPos, pass.aosTime, pass.losTime)
satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime
)
_uiState.update { it.copy(satTrack = track) } _uiState.update { it.copy(satTrack = track) }
} }
// Tick loop — position, timer, and radio updates every second // Tick loop — position, timer, and radio updates every second
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow) val sunPos = CelestialComputer.getSunPosition(stationPos, timeNow)
val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow) val moonPos = CelestialComputer.getMoonPosition(stationPos, timeNow)
val (time, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow) val (time, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
val isLos = !satPass.isDeepSpace && timeNow > satPass.losTime val isLos = !pass.isDeepSpace && timeNow > pass.losTime
_uiState.update { _uiState.update {
it.copy( it.copy(
currentTime = time, currentTime = time,
@@ -115,7 +111,7 @@ class RadarViewModel(
moonPosition = moonPos moonPosition = moonPos
) )
} }
processRadios(transmitters, satPass.orbitalObject, timeNow) processRadios(transmitters, pass.orbitalObject, timeNow)
sendPassData(pos) sendPassData(pos)
delay(1000) delay(1000)
} }
@@ -213,11 +209,9 @@ class RadarViewModel(
} }
companion object { companion object {
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
RadarViewModel( RadarViewModel(
catNum = catNum,
aosTime = aosTime,
bluetoothReporter = container.provideBluetoothReporter(), bluetoothReporter = container.provideBluetoothReporter(),
networkReporter = container.provideNetworkReporter(), networkReporter = container.provideNetworkReporter(),
satelliteRepo = container.satelliteRepo, satelliteRepo = container.satelliteRepo,
@@ -66,14 +66,10 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
import java.util.Locale import java.util.Locale
@Composable @Composable
fun RadioControlDestination(catNum: Int = 0, aosTime: Long = 0L, navigateUp: () -> Unit) { fun RadioControlDestination(navigateUp: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: RadioControlViewModel = viewModel(factory = RadioControlViewModel.factory(container))
modelClass = RadioControlViewModel::class.java,
key = "$catNum-$aosTime",
factory = RadioControlViewModel.factory(catNum, aosTime, container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
RadioControlScreen(uiState, viewModel::onAction, navigateUp) RadioControlScreen(uiState, viewModel::onAction, navigateUp)
} }
@@ -38,8 +38,6 @@ import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
class RadioControlViewModel( class RadioControlViewModel(
private val catNum: Int,
private val aosTime: Long,
private val trackingService: IRadioTrackingService, private val trackingService: IRadioTrackingService,
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
settingsRepo: ISettingsRepo settingsRepo: ISettingsRepo
@@ -73,27 +71,23 @@ class RadioControlViewModel(
// Resolve pass and load transponders // Resolve pass and load transponders
viewModelScope.launch { viewModelScope.launch {
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val pass = passes.find { it.catNum == catNum && it.aosTime == aosTime } val (catNum, aosTime) = satelliteRepo.selectedPass.value
?: passes.firstOrNull() val satPass = passes.find { it.catNum == catNum && it.aosTime == aosTime } ?: passes.firstOrNull()
currentPass = pass currentPass = satPass
pass?.let { satPass -> satPass?.let { pass ->
val allRadios = satelliteRepo.getRadiosWithId(satPass.catNum) val allRadios = satelliteRepo.getRadiosWithId(pass.catNum)
transponders = allRadios.filter { it.downlinkLow != null } transponders = allRadios.filter { it.downlinkLow != null }
_uiState.update { _uiState.update { it.copy(currentPass = pass, transponders = transponders) }
it.copy(currentPass = satPass, transponders = transponders)
}
// If service is already tracking this pass, sync the selected transponder // If service is already tracking this pass, sync the selected transponder
val svcState = trackingService.state.value val svcState = trackingService.state.value
if (svcState.isActive && svcState.currentPass?.catNum == satPass.catNum) { if (svcState.isActive && svcState.currentPass?.catNum == pass.catNum) {
_uiState.update { _uiState.update { it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid) }
it.copy(selectedTransponderUuid = svcState.selectedTransponder?.uuid)
}
} }
// Tick loop — timer and satellite position updates every second // Tick loop — timer and satellite position updates every second
while (isActive) { while (isActive) {
val timeNow = System.currentTimeMillis() val timeNow = System.currentTimeMillis()
val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) val pos = satelliteRepo.getPosition(pass.orbitalObject, stationPos, timeNow)
val (timeStr, isAos) = computeTimer(satPass.isDeepSpace, satPass.aosTime, satPass.losTime, timeNow) val (timeStr, isAos) = computeTimer(pass.isDeepSpace, pass.aosTime, pass.losTime, timeNow)
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
currentTime = timeStr, currentTime = timeStr,
@@ -187,11 +181,9 @@ class RadioControlViewModel(
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz) return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
} }
fun factory(catNum: Int, aosTime: Long, container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {
RadioControlViewModel( RadioControlViewModel(
catNum = catNum,
aosTime = aosTime,
trackingService = container.radioTrackingService, trackingService = container.radioTrackingService,
satelliteRepo = container.satelliteRepo, satelliteRepo = container.satelliteRepo,
settingsRepo = container.settingsRepo settingsRepo = container.settingsRepo
@@ -75,10 +75,7 @@ import com.rtbishop.look4sat.core.presentation.layoutPadding
fun SatellitesDestination(navigateUp: () -> Unit) { fun SatellitesDestination(navigateUp: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: SatellitesViewModel = viewModel(factory = SatellitesViewModel.factory(container))
modelClass = SatellitesViewModel::class.java,
factory = SatellitesViewModel.factory(container)
)
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
SatellitesScreen(uiState, viewModel::onAction, navigateUp) SatellitesScreen(uiState, viewModel::onAction, navigateUp)
} }
@@ -81,10 +81,7 @@ import java.util.Locale
fun SettingsDestination() { fun SettingsDestination() {
val context = LocalContext.current val context = LocalContext.current
val container = (context.applicationContext as IContainerProvider).getMainContainer() val container = (context.applicationContext as IContainerProvider).getMainContainer()
val viewModel = viewModel( val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
modelClass = SettingsViewModel::class.java,
factory = SettingsViewModel.factory(container)
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SettingsScreen(uiState, viewModel::onAction) SettingsScreen(uiState, viewModel::onAction)
} }
@@ -188,32 +185,63 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
val gitHubUrl = stringResource(R.string.prefs_github_url) val gitHubUrl = stringResource(R.string.prefs_github_url)
val licenseUrl = stringResource(R.string.prefs_license_url) val licenseUrl = stringResource(R.string.prefs_license_url)
val privacyUrl = stringResource(R.string.prefs_privacy_url) val privacyUrl = stringResource(R.string.prefs_privacy_url)
val safeOpenUri: (String) -> Unit = { url ->
try { uriHandler.openUri(url) } catch (_: Exception) {}
}
ScreenColumn( ScreenColumn(
topBar = { isVerticalLayout -> topBar = { isVerticalLayout ->
if (isVerticalLayout) { if (isVerticalLayout) {
TopBar { TopBar {
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
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)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f)) BotCard(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
} }
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license) IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} else { } else {
TopBar { TopBar {
PrimaryIconCard(onClick = { uriHandler.openUri(donateUrl) }, resId = R.drawable.ic_pound) PrimaryIconCard(onClick = { safeOpenUri(donateUrl) }, resId = R.drawable.ic_pound)
TopCard(onClick = { uriHandler.openUri(appUrl) }, version = uiState.appVersionName, modifier = Modifier.weight(1f)) TopCard(
onClick = { safeOpenUri(appUrl) },
version = uiState.appVersionName,
modifier = Modifier.weight(1f)
)
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
BotCard(onClick = { uriHandler.openUri(fdroidUrl) }, R.drawable.ic_fdroid, fdroidTitle, modifier = Modifier.weight(1f)) BotCard(
BotCard(onClick = { uriHandler.openUri(gitHubUrl) }, R.drawable.ic_github, gitHubTitle, modifier = Modifier.weight(1f)) onClick = { safeOpenUri(fdroidUrl) },
resId = R.drawable.ic_fdroid,
text = fdroidTitle,
modifier = Modifier.weight(1f)
)
BotCard(
onClick = { safeOpenUri(gitHubUrl) },
resId = R.drawable.ic_github,
text = gitHubTitle,
modifier = Modifier.weight(1f)
)
} }
IconCard(action = { uriHandler.openUri(licenseUrl) }, resId = R.drawable.ic_license) IconCard(action = { safeOpenUri(licenseUrl) }, resId = R.drawable.ic_license)
IconCard(action = { uriHandler.openUri(privacyUrl) }, resId = R.drawable.ic_policy) IconCard(action = { safeOpenUri(privacyUrl) }, resId = R.drawable.ic_policy)
} }
} }
} }
+7 -7
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "430" appVersionCode = "432"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.3.0" appVersionName = "4.3.2"
#noinspection GradleDependency,UnusedVersionCatalogEntry #noinspection GradleDependency,UnusedVersionCatalogEntry
compileSdk = "36" compileSdk = "36"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -12,21 +12,21 @@ jdkVersion = "17"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
android-gradle-plugin = "9.2.0" android-gradle-plugin = "9.2.1"
androidx-core-ktx = "1.18.0" androidx-core-ktx = "1.18.0"
androidx-core-splashscreen = "1.2.0" androidx-core-splashscreen = "1.2.0"
androidx-room = "2.8.4" androidx-room = "2.8.4"
compose-bom = "2026.04.01" compose-bom = "2026.05.01"
compose-activity = "1.13.0" compose-activity = "1.13.0"
compose-lifecycle = "2.10.0" compose-lifecycle = "2.10.0"
compose-navigation3 = "1.1.1" compose-navigation3 = "1.1.2"
google-ksp = "2.3.6" google-ksp = "2.3.7"
kotlin = "2.3.21" kotlin = "2.3.21"
kotlin-coroutines = "1.10.2" kotlin-coroutines = "1.11.0"
kotlin-serialization = "1.11.0" kotlin-serialization = "1.11.0"
other-okhttp = "5.3.2" other-okhttp = "5.3.2"
+2 -2
View File
@@ -1,7 +1,7 @@
#Tue Apr 28 15:46:23 BST 2026 #Mon May 25 11:20:48 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME