Added basic convention plugins setup for modularization

This commit is contained in:
Arty Bishop committed 2026-03-01 20:02:30 +00:00
1 parent de99e09f5e
commit 4d88214c1d
5675 files changed
+10339 -9969

No files matched your search

+1
View File
@@ -0,0 +1 @@
/build
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.convention.androidLibraryPlugin)
}
android {
namespace = "com.rtbishop.look4sat.core.data"
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,63 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
import com.rtbishop.look4sat.core.domain.model.SatItem
@Dao
interface Look4SatDao {
@Query("SELECT COUNT(*) FROM entries")
suspend fun getEntriesTotal(): Int
@Query("SELECT catnum, name, 0 as isSelected FROM entries ORDER BY name ASC")
suspend fun getEntriesList(): List<SatItem>
@Transaction
@Query("SELECT * FROM entries WHERE catnum IN (:selectedIds)")
suspend fun getEntriesWithIds(selectedIds: List<Int>): List<SatEntry>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertEntries(entries: List<SatEntry>)
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
suspend fun getRadiosTotal(): Int
@Transaction
@Query("SELECT * FROM radios WHERE catnum = :id AND isAlive = 1")
suspend fun getRadiosWithId(id: Int): List<SatRadio>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRadios(radios: List<SatRadio>)
@Query("DELETE FROM radios")
suspend fun deleteRadios()
}
@@ -0,0 +1,37 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao
}
//val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(database: SupportSQLiteDatabase) {
// 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))")
// 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")
// database.execSQL("DROP TABLE entries")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
// }
//}
@@ -0,0 +1,34 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.database.entity
import androidx.room.Entity
@Entity(tableName = "entries", primaryKeys = ["catnum"])
data class SatEntry(
val name: String,
val epoch: Double,
val meanmo: Double,
val eccn: Double,
val incl: Double,
val raan: Double,
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
)
@@ -0,0 +1,36 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.database.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "radios")
data class SatRadio(
@PrimaryKey val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
)
@@ -0,0 +1,140 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.util.Log
import com.rtbishop.look4sat.core.domain.repository.BtService
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.OutputStream
import java.util.UUID
import kotlin.math.abs
data class DeviceConnection(
var socket: BluetoothSocket? = null,
var outputStream: OutputStream? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class BluetoothReporter(
private val bluetoothManager: BluetoothManager,
private val reporterScope: CoroutineScope
) : IReporterRepo<WithoutExtParams> {
private val tag = "BTReporter"
private val sppid: UUID = UUID.fromString("00001101-0000-1000-8000-00805f9b34fb")
private val serviceToDevice = mutableMapOf<BtService, String>()
private val deviceConnections = mutableMapOf<String, DeviceConnection>()
private val writeMutex = Mutex()
override fun isConnected(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connected == true
}
override fun isConnecting(service: BtService): Boolean {
val deviceId = serviceToDevice[service] ?: return false
return deviceConnections[deviceId]?.connecting == true
}
override fun connect(service: BtService, deviceId: String) {
serviceToDevice[service] = deviceId
val connection = deviceConnections.getOrPut(deviceId) {
DeviceConnection()
}
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
try {
connection.connecting = true
val device = bluetoothManager.adapter.getRemoteDevice(deviceId)
val socket = device.createInsecureRfcommSocketToServiceRecord(sppid)
socket.connect()
connection.socket = socket
connection.outputStream = socket.outputStream
connection.connected = true
Log.i(tag, "$tag: Connected to $deviceId")
} catch (e: Exception) {
Log.e(tag, "$tag: ${e.message}")
connection.connected = false
} finally {
connection.connecting = false
}
}
}
private suspend fun write(service: BtService, buffer: String) {
val deviceId = serviceToDevice[service] ?: return
val connection = deviceConnections[deviceId] ?: return
if (!connection.connected) return
try {
writeMutex.withLock {
connection.outputStream?.write(buffer.toByteArray())
}
} catch (e: Exception) {
Log.e(tag, "$tag: Write failed ${e.message}")
connection.connected = false
}
}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: WithoutExtParams) {
reporterScope.launch {
if (!isConnected(BtService.ROTATOR)) return@launch
val newElevation = if (elevation > 0.0) elevation else 0.0
val azimuthString = intToStringWithLeadingZeroes(azimuth.toInt())
val elevationString = intToStringWithLeadingZeroes(newElevation.toInt())
val buffer = format
.replace($$"$AZ", azimuthString)
.replace($$"$EL", elevationString)
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.ROTATOR, buffer)
}
}
override fun reportFrequency(format: String, frequency: Long, params: WithoutExtParams) {
reporterScope.launch {
if (!isConnected(BtService.FREQUENCY)) return@launch
val buffer = format
.replace($$"$FREQ", frequency.toString())
.replace("\\r", "\r")
.replace("\\n", "\n")
.replace("\\t", "\t")
write(BtService.FREQUENCY, buffer)
}
}
private fun intToStringWithLeadingZeroes(value: Int): String {
return if (value > 0) {
if (value < 10) "00$value" else if (value < 100) "0$value" else "$value"
} else {
val absValue = abs(value)
if (value > -10) "-00$absValue" else if (value > -100) "-0$absValue" else "-$absValue"
}
}
}
@@ -0,0 +1,117 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.framework
import com.rtbishop.look4sat.core.domain.repository.BtService
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.SocketChannel
enum class NetworkService { ROTATOR, FREQUENCY }
data class SocketConnection(
var socket: SocketChannel? = null,
var connected: Boolean = false,
var connecting: Boolean = false,
var connectionJob: Job? = null
)
class NetworkReporter(private val reporterScope: CoroutineScope) : IReporterRepo<ExtendedParams> {
private val serviceToAddress = mutableMapOf<NetworkService, String>()
private val connections = mutableMapOf<String, SocketConnection>()
private val writeMutex = Mutex()
fun isConnected(service: NetworkService): Boolean {
val addr = serviceToAddress[service] ?: return false
return connections[addr]?.connected == true
}
private fun getOrCreateConnection(addr: String): SocketConnection {
return connections.getOrPut(addr) { SocketConnection() }
}
fun connect(service: NetworkService, server: String, port: Int) {
val addr = "$server:$port"
serviceToAddress[service] = addr
val connection = getOrCreateConnection(addr)
if (connection.connected || connection.connecting) return
connection.connectionJob = reporterScope.launch {
try {
connection.connecting = true
val socket = SocketChannel.open(InetSocketAddress(server, port))
connection.socket = socket
connection.connected = true
println("NetworkReporter: $service connected to $addr")
} catch (e: Exception) {
println("NetworkReporter connect error: ${e.message}")
connection.connected = false
} finally {
connection.connecting = false
}
}
}
private suspend fun write(service: NetworkService, command: String) {
val addr = serviceToAddress[service] ?: return
val connection = connections[addr] ?: return
if (!connection.connected) return
try {
writeMutex.withLock {
val buffer = ByteBuffer.wrap("\\$command\n".toByteArray())
connection.socket?.write(buffer)
}
} catch (e: Exception) {
println("NetworkReporter write error: ${e.message}")
connection.connected = false
}
}
override fun isConnected(service: BtService): Boolean = false
override fun isConnecting(service: BtService): Boolean = false
override fun connect(service: BtService, deviceId: String) {}
override fun reportRotation(format: String, azimuth: Double, elevation: Double, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.ROTATOR, params.server, params.port)
if (!isConnected(NetworkService.ROTATOR)) return@launch
val el = if (elevation > 0.0) elevation else 0.0
val command = format
.replace("\$AZ", azimuth.toString())
.replace("\$EL", el.toString())
write(NetworkService.ROTATOR, command)
}
}
override fun reportFrequency(format: String, frequency: Long, params: ExtendedParams) {
reporterScope.launch {
connect(NetworkService.FREQUENCY, params.server, params.port)
if (!isConnected(NetworkService.FREQUENCY)) return@launch
val command = format.replace("\$FREQ", frequency.toString())
write(NetworkService.FREQUENCY, command)
}
}
}
@@ -0,0 +1,102 @@
package com.rtbishop.look4sat.core.data.injection
import android.bluetooth.BluetoothManager
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.location.LocationManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.ShowToast
import com.rtbishop.look4sat.core.domain.repository.ExtendedParams
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
import com.rtbishop.look4sat.core.domain.repository.IReporterRepo
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.repository.WithoutExtParams
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okhttp3.OkHttpClient
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams> {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
return BluetoothReporter(manager, CoroutineScope(Dispatchers.IO))
}
override fun provideNetworkReporter(): IReporterRepo<ExtendedParams> {
return NetworkReporter(CoroutineScope(Dispatchers.IO))
}
override fun provideSensorsRepo(): ISensorsRepo {
val manager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = manager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
return SensorsRepo(manager, sensor)
}
private fun provideDatabaseRepo(): IDatabaseRepo {
val dbDispatcher = Dispatchers.Default
val dataParser = DataParser(dbDispatcher)
val remoteSource = provideRemoteSource()
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
}
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build()
return LocalSource(database.look4SatDao())
}
private fun provideRemoteSource(): IRemoteSource {
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
}
private fun provideSatelliteRepo(): ISatelliteRepo {
return SatelliteRepo(Dispatchers.Default, localSource, settingsRepo)
}
private fun provideSelectionRepo(): ISelectionRepo {
return SelectionRepo(Dispatchers.Default, localSource, settingsRepo)
}
private fun provideSettingsRepo(): ISettingsRepo {
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val appPrefsFileName = "${context.packageName}_preferences"
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
return SettingsRepo(manager, appPreferences, appVersionName)
}
}
@@ -0,0 +1,130 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.util.zip.ZipInputStream
class DatabaseRepo(
private val dispatcher: CoroutineDispatcher,
private val dataParser: DataParser,
private val localSource: ILocalSource,
private val remoteSource: IRemoteSource,
private val settingsRepo: ISettingsRepo
) : IDatabaseRepo {
override suspend fun updateTLEFromFile(uri: String) = withContext(dispatcher) {
val importedSatellites = remoteSource.getFileStream(uri)?.let { dataParser.parseTLEStream(it) }
importedSatellites?.let { localSource.insertEntries(it) }
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateTransceiversFromFile(uri: String) = withContext(dispatcher) {
val radios = remoteSource.getFileStream(uri)?.let { dataParser.parseJSONStream(it) }
radios?.let {
if(it.isNotEmpty()) {
localSource.deleteRadios()
localSource.insertRadios(it)
}
}
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun updateFromRemote() = withContext(dispatcher) {
val importedEntries = mutableListOf<OrbitalData>()
val importedRadios = mutableListOf<SatRadio>()
val tleUrls = Sources.satelliteDataUrls.toMutableMap().apply {
if (settingsRepo.dataSourcesSettings.value.useCustomTLE) {
this["Other"] = settingsRepo.dataSourcesSettings.value.tleUrl
}
}
val jobsMap = tleUrls
.filter { (_, url) -> url.isNotEmpty() }
.mapValues { (_, url) -> async { remoteSource.getNetworkStream(url) } }
val radioUrls = buildList {
add(Sources.RADIO_DATA_URL)
if (settingsRepo.dataSourcesSettings.value.useCustomTransceivers) {
add(settingsRepo.dataSourcesSettings.value.transceiversUrl)
}
}
val jobRadios = radioUrls.associateWith { url -> async { remoteSource.getNetworkStream(url) } }
// parse
jobsMap.mapValues { job -> job.value.await() }.forEach { entry ->
entry.value?.let { stream ->
when (val type = entry.key) {
"Amsat", "R4UAB", "Other" -> {
// parse tle stream
val satellites = dataParser.parseTLEStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
"McCants", "Classified" -> {
// unzip and parse tle stream
val unzipped = ZipInputStream(stream).apply { nextEntry }
val satellites = dataParser.parseTLEStream(unzipped)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
else -> {
// parse csv stream
val satellites = dataParser.parseCSVStream(stream)
val catnums = satellites.map { it.catnum }
settingsRepo.setSatelliteTypeIds(type, catnums)
importedEntries.addAll(satellites)
}
}
}
}
jobRadios.values.forEach { job ->
job.await()?.let {
importedRadios.addAll(dataParser.parseJSONStream(it))
}
}
// insert
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
setUpdateSuccessful(System.currentTimeMillis())
}
override suspend fun clearAllData() = withContext(dispatcher) {
localSource.deleteEntries()
localSource.deleteRadios()
setUpdateSuccessful(0L)
}
private suspend fun setUpdateSuccessful(timestamp: Long) = withContext(dispatcher) {
val numberOfRadios = localSource.getRadiosTotal()
val numberOfSatellites = localSource.getEntriesTotal()
settingsRepo.updateDatabaseState(DatabaseState(numberOfRadios, numberOfSatellites, timestamp))
}
}
@@ -0,0 +1,242 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.utility.round
import com.rtbishop.look4sat.core.domain.utility.toDegrees
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
private val localStorage: ILocalSource,
private val settingsRepo: ISettingsRepo
) : ISatelliteRepo {
private val _passes = MutableStateFlow<List<OrbitalPass>>(emptyList())
override val passes: StateFlow<List<OrbitalPass>> = _passes
private val _satellites = MutableStateFlow<List<OrbitalObject>>(emptyList())
override val satellites: StateFlow<List<OrbitalObject>> = _satellites
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value
val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000)
calculatePasses(timeNow, hoursAhead, minElevation, modes)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
return withContext(dispatcher) { sat.getPosition(pos, time) }
}
override suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> {
return withContext(dispatcher) {
val positions = mutableListOf<OrbitalPos>()
var currentTime = start
while (currentTime < end) {
positions.add(sat.getPosition(pos, currentTime))
currentTime += 15000
}
positions
}
}
override suspend fun getRadios(
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
val copiedList = radios.map { it.copy() }
copiedList.forEach { transmitter ->
transmitter.downlinkLow?.let { transmitter.downlinkLow = satPos.getDownlinkFreq(it) }
transmitter.downlinkHigh?.let { transmitter.downlinkHigh = satPos.getDownlinkFreq(it) }
transmitter.uplinkLow?.let { transmitter.uplinkLow = satPos.getUplinkFreq(it) }
transmitter.uplinkHigh?.let { transmitter.uplinkHigh = satPos.getUplinkFreq(it) }
}
copiedList.map { it.copy() }
}
}
override suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass> {
return withContext(dispatcher) {
passList.forEach { pass ->
if (!pass.isDeepSpace) {
val timeStart = pass.aosTime
if (time > timeStart) {
val deltaNow = time.minus(timeStart).toFloat()
val deltaTotal = pass.losTime.minus(timeStart).toFloat()
pass.progress = (deltaNow / deltaTotal).round(2)
}
}
}
passList.filter { pass -> pass.progress < 1.0 }.map { it.copy() }
}
}
override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>) {
if (_satellites.value.isNotEmpty()) {
withContext(dispatcher) {
val newPasses = mutableListOf<OrbitalPass>()
val idsWithModes = localStorage.getIdsWithModes(modes)
_satellites.value.forEach { satellite ->
if (idsWithModes.isEmpty() || satellite.data.catnum in idsWithModes) {
newPasses.addAll(satellite.getPasses(settingsRepo.stationPosition.value, time, hoursAhead))
}
}
_passes.update { newPasses.filter(time, hoursAhead, minElevation) }
}
} else {
_passes.update { emptyList() }
}
}
private fun OrbitalObject.getPasses(pos: GeoPos, time: Long, hours: Int): List<OrbitalPass> {
val passes = mutableListOf<OrbitalPass>()
val endDate = time + hours * 60L * 60L * 1000L
val quarterOrbitMin = (this.data.orbitalPeriod / 4.0).toInt()
var startDate = time
var shouldRewind = true
var lastAosDate: Long
var count = 0
if (this.willBeSeen(pos)) {
if (this.data.isDeepSpace) {
passes.add(getGeoPass(this, pos, time))
} else {
do {
if (count > 0) shouldRewind = false
val pass = getLeoPass(this, pos, startDate, shouldRewind)
lastAosDate = pass.aosTime
passes.add(pass)
startDate = pass.losTime + (quarterOrbitMin * 3) * 60L * 1000L
count++
} while (lastAosDate < endDate)
}
}
return passes
}
private fun List<OrbitalPass>.filter(time: Long, hoursAhead: Int, minElev: Double): List<OrbitalPass> {
val timeFuture = time + (hoursAhead * 60L * 60L * 1000L)
return this.filter { it.losTime > time }.filter { it.aosTime < timeFuture }
.filter { it.maxElevation > minElev }.sortedBy { it.aosTime }
}
private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass {
val satPos = sat.getPosition(pos, time)
val aos = time - 24 * 60L * 60L * 1000L
val los = time + 24 * 60L * 60L * 1000L // val tca = (aos + los) / 2
val az = satPos.azimuth.toDegrees().round(1)
val elev = satPos.elevation.toDegrees().round(1)
val alt = satPos.altitude
return OrbitalPass(aos, az, los, az, alt.toInt(), elev, sat)
}
private fun getLeoPass(sat: OrbitalObject, pos: GeoPos, time: Long, rewind: Boolean): OrbitalPass {
val quarterOrbitMin = (sat.data.orbitalPeriod / 4.0).toInt()
var calendarTimeMillis = time
var elevation: Double
var maxElevation = 0.0
var alt = 0.0 // var tcaAz = 0.0
// rewind 1/4 of an orbit
if (rewind) calendarTimeMillis += -quarterOrbitMin * 60L * 1000L
var satPos = sat.getPosition(pos, calendarTimeMillis)
if (satPos.elevation > 0.0) {
// move forward in 30 second intervals until the sat goes below the horizon
do {
calendarTimeMillis += 30 * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
} while (satPos.elevation > 0.0)
// move forward 3/4 of an orbit
calendarTimeMillis += quarterOrbitMin * 3 * 60L * 1000L
}
// find the next time sat comes above the horizon
do {
calendarTimeMillis += 60L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
// refine to 1 second
calendarTimeMillis += -60L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation < 0.0)
val aos = 1000 * ((satPos.time + 500) / 1000)
val aosAz = satPos.azimuth.toDegrees().round(1)
// find when sat goes below
do {
calendarTimeMillis += 30L * 1000L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
// refine to 1 second
calendarTimeMillis += -30L * 1000L
do {
calendarTimeMillis += 1L * 500L
satPos = sat.getPosition(pos, calendarTimeMillis)
elevation = satPos.elevation
if (elevation > maxElevation) {
maxElevation = elevation
alt = satPos.altitude // tcaAz = satPos.azimuth.toDegrees()
}
} while (satPos.elevation > 0.0)
val los = 1000 * ((satPos.time + 500) / 1000) // val tca = (aos + los) / 2
val losAz = satPos.azimuth.toDegrees().round(1)
val elev = maxElevation.toDegrees().round(1)
return OrbitalPass(aos, aosAz, los, losAz, alt.toInt(), elev, sat)
}
}
@@ -0,0 +1,103 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.domain.source.Sources
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepo(
private val dispatcher: CoroutineDispatcher,
private val localSource: ILocalSource,
private val settingsRepo: ISettingsRepo
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
currentItems.map { items -> items.filterByTypes(types) }
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> items.filterByQuery(query) }
}
override fun getCurrentTypes() = currentTypes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value
currentItems.value = localSource.getEntriesList().map { item ->
item.copy(isSelected = item.catnum in selectedIds)
}
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
}
override suspend fun setQuery(query: String) = withContext(dispatcher) {
currentQuery.value = query
}
override suspend fun setSelection(selectAll: Boolean) = withContext(dispatcher) {
setSelection(itemsWithQuery.first().map { item -> item.catnum }, selectAll)
}
override suspend fun setSelection(ids: List<Int>, isTicked: Boolean) = withContext(dispatcher) {
currentItems.value = currentItems.value.map { item ->
if (item.catnum in ids) item.copy(isSelected = isTicked) else item
}
}
override suspend fun saveSelection() = withContext(dispatcher) {
val currentSelection = currentItems.value.filter { it.isSelected }.map { it.catnum }
settingsRepo.setSelectedIds(currentSelection)
}
private suspend fun List<SatItem>.filterByTypes(types: List<String>) = withContext(dispatcher) {
if (types.isEmpty()) return@withContext this@filterByTypes
val catnums = settingsRepo.getSatelliteTypesIds(types)
if (catnums.isEmpty()) return@withContext this@filterByTypes
return@withContext this@filterByTypes.filter { item -> item.catnum in catnums }
}
private suspend fun List<SatItem>.filterByQuery(query: String) = withContext(dispatcher) {
if (query.isBlank()) return@withContext this@filterByQuery
return@withContext try {
this@filterByQuery.filter { it.catnum == query.toInt() }
} catch (_: Exception) {
this@filterByQuery.filter { item -> item.name.lowercase().contains(query.lowercase()) }
}
}
}
@@ -0,0 +1,74 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import android.hardware.GeomagneticField
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.round
class SensorsRepo(private val sensorManager: SensorManager, private val sensor: Sensor?) :
SensorEventListener, ISensorsRepo {
private val _orientation = MutableStateFlow(Pair(0f, 0f))
private val rotationMatrix = FloatArray(9)
private val orientationValues = FloatArray(3)
private var sensorAccuracy = SensorManager.SENSOR_STATUS_UNRELIABLE
override val orientation: StateFlow<Pair<Float, Float>> = _orientation
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
val latitude = geoPos.latitude.toFloat()
val longitude = geoPos.longitude.toFloat()
return GeomagneticField(latitude, longitude, geoPos.altitude.toFloat(), time).declination
}
override fun enableSensor() {
sensor?.let { sensorManager.registerListener(this, it, 8000) }
}
override fun disableSensor() = sensorManager.unregisterListener(this)
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
sensorAccuracy = accuracy
}
override fun onSensorChanged(event: SensorEvent) = when {
sensorAccuracy == SensorManager.SENSOR_STATUS_UNRELIABLE -> {}
event.sensor == sensor -> updateOrientation(event.values)
else -> {}
}
private fun updateOrientation(rotationVector: FloatArray) {
SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)
SensorManager.getOrientation(rotationMatrix, orientationValues)
val azimuth = (orientationValues[0] * RAD2DEG).toFloat()
val pitch = (orientationValues[1] * RAD2DEG).toFloat() // roll [2]
val magneticAzimuth = (azimuth + 360f) % 360f
val roundedAzimuth = round(magneticAzimuth * 10) / 10
val roundedPitch = round(pitch * 10) / 10
_orientation.value = Pair(roundedAzimuth, roundedPitch)
}
}
@@ -0,0 +1,427 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import android.content.SharedPreferences
import android.location.Location
import android.location.LocationManager
import androidx.core.content.edit
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class SettingsRepo(
private val locationManager: LocationManager,
private val preferences: SharedPreferences,
override val appVersionName: String
) : ISettingsRepo, LocationListenerCompat {
private val keyBluetoothRotatorAddress = "bluetoothAddress"
private val keyBluetoothRotatorName = "bluetoothName"
private val keyBluetoothRotatorFormat = "bluetoothFormat"
private val keyBluetoothRotatorState = "bluetoothState"
private val keyBluetoothFrequencyState = "bluetoothFrequencyState"
private val keyBluetoothFrequencyAddress = "bluetoothFrequencyAddress"
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
private val keyFilterHoursAhead = "filterHoursAhead"
private val keyFilterMinElevation = "filterMinElevation"
private val keyNumberOfRadios = "numberOfRadios"
private val keyNumberOfSatellites = "numberOfSatellites"
private val keyRotatorAddress = "rotatorAddress"
private val keyRotatorPort = "rotatorPort"
private val keyRotatorState = "rotatorState"
private val keyRotatorFormat = "rotatorFormat"
private val keyFrequencyState = "frequencyState"
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
private val keyStateOfUtc = "stateOfUtc"
private val keyStateOfLightTheme = "stateOfLightTheme"
private val keyStationAltitude = "stationAltitude"
private val keyStationLatitude = "stationLatitude"
private val keyStationLongitude = "stationLongitude"
private val keyStationQth = "stationQth"
private val keyStationTimestamp = "stationTimestamp"
private val keyUpdateTimestamp = "updateTimestamp"
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
private val keyTransceiversUrl = "transceiversUrl"
private val separatorComma = ","
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
preferences.edit { putString(keySelectedIds, selectionString) }
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
}
private fun getSelectedIds(): List<Int> {
val selectionString = preferences.getString(keySelectedIds, null)
if (selectionString.isNullOrEmpty()) return emptyList()
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, null)
if (typesString.isNullOrEmpty()) return listOf("Amateur")
return typesString.split(separatorComma)
}
//endregion
//region # Passes filter settings
private val _passesSettings = MutableStateFlow(getPassesSettings())
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
putInt(keyFilterHoursAhead, settings.hoursAhead)
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
private fun getPassesSettings(): PassesSettings {
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(hoursAhead, minElevation, selectedModes)
}
//endregion
//region # Station position settings
private val _stationPosition = MutableStateFlow(getStationPosition())
private val providerDef = LocationManager.PASSIVE_PROVIDER
private val providerGps = LocationManager.GPS_PROVIDER
private val providerNet = LocationManager.NETWORK_PROVIDER
override val stationPosition: StateFlow<GeoPos> = _stationPosition
override fun onLocationChanged(location: Location) {
setStationPosition(location.latitude, location.longitude, location.altitude)
}
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean {
val newLongitude = if (longitude > 180.0) longitude - 180 else longitude
val locator = positionToQth(latitude, newLongitude) ?: return false
setStationPosition(latitude, newLongitude, altitude, locator)
return true
}
override fun setStationPosition(): Boolean {
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
try {
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
val location = locationManager.getLastKnownLocation(providerDef)
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
println("Requesting location for $provider provider")
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
} else {
setStationPosition(location.latitude, location.longitude, location.altitude)
}
} catch (exception: SecurityException) {
println("No permissions were given - $exception")
}
return true
}
override fun setStationPosition(locator: String): Boolean {
val position = qthToPosition(locator) ?: return false
setStationPosition(position.latitude, position.longitude, 0.0, locator)
return true
}
private fun getStationPosition(): GeoPos {
val latitude = (preferences.getString(keyStationLatitude, null) ?: "0.0").toDouble()
val longitude = (preferences.getString(keyStationLongitude, null) ?: "0.0").toDouble()
val altitude = (preferences.getString(keyStationAltitude, null) ?: "0.0").toDouble()
val qthLocator = preferences.getString(keyStationQth, null) ?: "JJ00aa"
val timestamp = preferences.getLong(keyStationTimestamp, 0L)
return GeoPos(latitude, longitude, altitude, qthLocator, timestamp)
}
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(4)
val newLon = longitude.round(4)
val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
setStationPosition(GeoPos(newLat, newLon, newAlt, locator, timestamp))
}
private fun setStationPosition(stationPos: GeoPos) = preferences.edit {
putString(keyStationLatitude, stationPos.latitude.toString())
putString(keyStationLongitude, stationPos.longitude.toString())
putString(keyStationAltitude, stationPos.altitude.toString())
putString(keyStationQth, stationPos.qthLocator)
putLong(keyStationTimestamp, stationPos.timestamp)
_stationPosition.value = stationPos
}
//endregion
//region # Database update settings
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
val typeString = preferences.getString("type$type", null)
val typeIds = if (typeString.isNullOrBlank()) {
emptyList()
} else {
typeString.split(separatorComma).map { it.toInt() }
}
idsSet.addAll(typeIds)
}
return idsSet.toList()
}
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
if (type == "All") return
val typesString = ids.joinToString(separatorComma)
preferences.edit { putString("type$type", typesString) }
}
override fun updateDatabaseState(state: DatabaseState) = preferences.edit {
putInt(keyNumberOfSatellites, state.numberOfSatellites)
putInt(keyNumberOfRadios, state.numberOfRadios)
putLong(keyUpdateTimestamp, state.updateTimestamp)
_databaseState.value = state
}
private fun getDatabaseState(): DatabaseState {
val numberOfRadios = preferences.getInt(keyNumberOfRadios, 0)
val numberOfSatellites = preferences.getInt(keyNumberOfSatellites, 0)
val updateTimestamp = preferences.getLong(keyUpdateTimestamp, 0L)
return DatabaseState(numberOfRadios, numberOfSatellites, updateTimestamp)
}
//endregion
//region # RC settings
private val _rcSettings = MutableStateFlow(getRCSettings())
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun setBluetoothRotatorAddress(value: String) {
preferences.edit { putString(keyBluetoothRotatorAddress, value) }
_rcSettings.update { it.copy(bluetoothRotatorAddress = value) }
}
override fun setBluetoothRotatorFormat(value: String) {
preferences.edit { putString(keyBluetoothRotatorFormat, value) }
_rcSettings.update { it.copy(bluetoothRotatorFormat = value) }
}
override fun setBluetoothRotatorName(value: String) {
preferences.edit { putString(keyBluetoothRotatorName, value) }
_rcSettings.update { it.copy(bluetoothRotatorName = value) }
}
override fun setBluetoothRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothRotatorState, value) }
_rcSettings.update { it.copy(bluetoothRotatorState = value) }
}
override fun setBluetoothFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyBluetoothFrequencyState, value) }
_rcSettings.update { it.copy(bluetoothFrequencyState = value) }
}
override fun setBluetoothFrequencyAddress(value: String) {
preferences.edit { putString(keyBluetoothFrequencyAddress, value) }
_rcSettings.update { it.copy(bluetoothFrequencyAddress = value) }
}
override fun setBluetoothFrequencyFormat(value: String) {
preferences.edit { putString(keyBluetoothFrequencyFormat, value) }
_rcSettings.update { it.copy(bluetoothFrequencyFormat = value) }
}
override fun setRotatorAddress(value: String) {
preferences.edit { putString(keyRotatorAddress, value) }
_rcSettings.update { it.copy(rotatorAddress = value) }
}
override fun setRotatorPort(value: String) {
preferences.edit { putString(keyRotatorPort, value) }
_rcSettings.update { it.copy(rotatorPort = value) }
}
override fun setRotatorState(value: Boolean) {
preferences.edit { putBoolean(keyRotatorState, value) }
_rcSettings.update { it.copy(rotatorState = value) }
}
override fun setRotatorFormat(value: String) {
preferences.edit { putString(keyRotatorFormat, value) }
_rcSettings.update { it.copy(rotatorFormat = value) }
}
override fun setFrequencyState(value: Boolean) {
preferences.edit { putBoolean(keyFrequencyState, value) }
_rcSettings.update { it.copy(frequencyState = value) }
}
override fun setFrequencyAddress(value: String) {
preferences.edit { putString(keyFrequencyAddress, value) }
_rcSettings.update { it.copy(frequencyAddress = value) }
}
override fun setFrequencyPort(value: String) {
preferences.edit { putString(keyFrequencyPort, value) }
_rcSettings.update { it.copy(frequencyPort = value) }
}
override fun setFrequencyFormat(value: String) {
preferences.edit { putString(keyFrequencyFormat, value) }
_rcSettings.update { it.copy(frequencyFormat = value) }
}
private fun getRCSettings(): RCSettings = RCSettings(
rotatorState = preferences.getBoolean(keyRotatorState, false),
rotatorAddress = preferences.getString(keyRotatorAddress, null) ?: "127.0.0.1",
rotatorPort = preferences.getString(keyRotatorPort, null) ?: "4533",
rotatorFormat = preferences.getString(keyRotatorFormat, null) ?: $$"P $AZ $EL",
frequencyState = preferences.getBoolean(keyFrequencyState, false),
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"set_freq $FREQ",
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"W$AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
bluetoothRotatorAddress = preferences.getString(keyBluetoothRotatorAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyState = preferences.getBoolean(keyBluetoothFrequencyState, false),
bluetoothFrequencyAddress = preferences.getString(keyBluetoothFrequencyAddress, null) ?: "00:0C:BF:13:80:5D",
bluetoothFrequencyFormat = preferences.getString(keyBluetoothFrequencyFormat, null) ?: $$"FA$FREQ"
)
//endregion
//region # Other settings
private val _otherSettings = MutableStateFlow(getOtherSettings())
override val otherSettings: StateFlow<OtherSettings> = _otherSettings
override fun setStateOfAutoUpdate(value: Boolean) {
preferences.edit { putBoolean(keyStateOfAutoUpdate, value) }
_otherSettings.update { it.copy(stateOfAutoUpdate = value) }
}
override fun setStateOfSensors(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSensors, value) }
_otherSettings.update { it.copy(stateOfSensors = value) }
}
override fun setStateOfSweep(value: Boolean) {
preferences.edit { putBoolean(keyStateOfSweep, value) }
_otherSettings.update { it.copy(stateOfSweep = value) }
}
override fun setStateOfUtc(value: Boolean) {
preferences.edit { putBoolean(keyStateOfUtc, value) }
_otherSettings.update { it.copy(stateOfUtc = value) }
}
override fun setStateOfLightTheme(value: Boolean){
preferences.edit { putBoolean(keyStateOfLightTheme, value) }
_otherSettings.update { it.copy(stateOfLightTheme = value) }
}
override fun setWarningDismissed() {
preferences.edit { putBoolean(keyShouldSeeWarning, false) }
_otherSettings.update { it.copy(shouldSeeWarning = false) }
}
override fun setWhatsNewDismissed() {
preferences.edit { putBoolean(keyShouldSeeWhatsNew, false) }
_otherSettings.update { it.copy(shouldSeeWhatsNew = false) }
}
private fun getOtherSettings(): OtherSettings = OtherSettings(
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true)
)
//endregion
//region # Data sources settings
private val _dataSourcesSettings = MutableStateFlow(getDataSourcesSettings())
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
override fun setUseCustomTle(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTle, value) }
_dataSourcesSettings.update { it.copy(useCustomTLE = value) }
}
override fun setUseCustomTransceivers(value: Boolean) {
preferences.edit { putBoolean(keyUseCustomTransceivers, value) }
_dataSourcesSettings.update { it.copy(useCustomTransceivers = value) }
}
override fun setTleUrl(value: String) {
preferences.edit { putString(keyTleUrl, value) }
_dataSourcesSettings.update { it.copy(tleUrl = value) }
}
override fun setTransceiversUrl(value: String) {
preferences.edit { putString(keyTransceiversUrl, value) }
_dataSourcesSettings.update { it.copy(transceiversUrl = value) }
}
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
)
//endregion
}
@@ -0,0 +1,98 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.source
import com.rtbishop.look4sat.core.data.database.Look4SatDao
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import com.rtbishop.look4sat.core.data.database.entity.SatEntry as FrameworkEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio as FrameworkRadio
import com.rtbishop.look4sat.core.domain.model.SatRadio as DomainRadio
class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
//region # Entries region
override suspend fun getEntriesTotal() = look4SatDao.getEntriesTotal()
override suspend fun getEntriesList() = look4SatDao.getEntriesList()
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> {
val selectedOrbitalObjects = mutableListOf<OrbitalObject>()
ids.chunked(999).forEach { idsPart ->
val entries = look4SatDao.getEntriesWithIds(idsPart)
selectedOrbitalObjects.addAll(entries.toDomain().map { entry -> entry.getObject() })
}
return selectedOrbitalObjects
}
override suspend fun insertEntries(entries: List<OrbitalData>) = look4SatDao.insertEntries(entries.toEntity())
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
)
private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
)
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
private fun List<FrameworkEntry>.toDomain() = this.map { item -> item.toDomain() }
//endregion
//region # Radios region
override suspend fun getRadiosTotal() = look4SatDao.getRadiosTotal()
override suspend fun getRadiosWithId(id: Int): List<SatRadio> {
return look4SatDao.getRadiosWithId(id).toDomainRadios()
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.deleteRadios()
look4SatDao.insertRadios(radios.toFrameworkRadios())
}
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
)
private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
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() }
//endregion
}
@@ -0,0 +1,54 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.source
import android.content.ContentResolver
import androidx.core.net.toUri
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
class RemoteSource(
private val dispatcher: CoroutineDispatcher,
private val contentResolver: ContentResolver,
private val httpClient: OkHttpClient
) : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
try {
val fileUri = uri.toUri()
contentResolver.openInputStream(fileUri)?.buffered()
} catch (exception: Exception) {
println("RemoteSource file stream exception: $exception")
null
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().body.byteStream()
} catch (exception: Exception) {
println("RemoteSource network stream exception: $exception")
null
}
}
}
@@ -0,0 +1,41 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.usecase
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
class AddToCalendar(private val context: Context) : IAddToCalendar {
override fun invoke(name: String, aosTime: Long, losTime: Long) {
val intent = Intent(Intent.ACTION_INSERT).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setData(CalendarContract.Events.CONTENT_URI)
putExtra(CalendarContract.Events.TITLE, name)
putExtra(CalendarContract.Events.DESCRIPTION, "Look4Sat Pass")
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, aosTime)
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, losTime)
}
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
}
}
}
@@ -0,0 +1,28 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.usecase
import android.content.Context
import android.widget.Toast
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
class ShowToast(private val context: Context) : IShowToast {
override fun invoke(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
+1
View File
@@ -0,0 +1 @@
/build
+3
View File
@@ -0,0 +1,3 @@
plugins {
alias(libs.plugins.convention.kotlinLibraryPlugin)
}
@@ -0,0 +1,20 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
data class SatItem(val catnum: Int, val name: String, val isSelected: Boolean = false)
@@ -0,0 +1,32 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
data class SatRadio(
val uuid: String,
val info: String,
val isAlive: Boolean,
var downlinkLow: Long?,
var downlinkHigh: Long?,
val downlinkMode: String?,
var uplinkLow: Long?,
var uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
)
@@ -0,0 +1,65 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.model
data class DatabaseState(
val numberOfRadios: Int,
val numberOfSatellites: Int,
val updateTimestamp: Long
)
data class PassesSettings(
val hoursAhead: Int,
val minElevation: Double,
val selectedModes: List<String>
)
data class RCSettings(
val rotatorState: Boolean,
val rotatorAddress: String,
val rotatorPort: String,
val rotatorFormat: String,
val frequencyState: Boolean,
val frequencyAddress: String,
val frequencyPort: String,
val frequencyFormat: String,
val bluetoothRotatorState: Boolean,
val bluetoothRotatorFormat: String,
val bluetoothRotatorName: String,
val bluetoothRotatorAddress: String,
val bluetoothFrequencyState: Boolean,
val bluetoothFrequencyFormat: String,
val bluetoothFrequencyAddress: String
)
data class OtherSettings(
val stateOfAutoUpdate: Boolean,
val stateOfSensors: Boolean,
val stateOfSweep: Boolean,
val stateOfUtc: Boolean,
val stateOfLightTheme: Boolean,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean
)
data class DataSourcesSettings(
val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean,
val tleUrl: String,
val transceiversUrl: String
)
@@ -0,0 +1,37 @@
/*
* 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
const val ASTRONOMICAL_UNIT = 1.49597870691E8
const val DEG2RAD = 0.017453292519943295
const val RAD2DEG = 57.29577951308232
const val EARTH_RADIUS = 6378.137
const val EPSILON = 1.0E-12
const val FLAT_FACT = 3.35281066474748E-3
const val J3_HARMONIC = -2.53881E-6
const val MIN_PER_DAY = 1.44E3
const val SEC_PER_DAY = 8.6400E4
const val SOLAR_RADIUS = 6.96000E5
const val SPEED_OF_LIGHT = 2.99792458E8
const val PI = 3.141592653589793
const val PI_2 = PI / 2.0
const val TWO_PI = PI * 2.0
const val TWO_THIRDS = 2.0 / 3.0
const val CK2 = 5.413079E-4
const val CK4 = 6.209887E-7
const val XKE = 7.43669161E-2
@@ -0,0 +1,853 @@
/*
* 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 kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
class DeepSpaceObject(data: OrbitalData) : OrbitalObject(data) {
private val c1: Double
private val c4: Double
private val x1mth2: Double
private val x3thm1: Double
private val xlcof: Double
private val xnodcf: Double
private val t2cof: Double
private val aycof: Double
private val x7thm1: Double
private val deep: DeepSpaceCalculator
private val dsv = DeepSpaceValueObject()
init {
// Recover original mean motion (xnodp) and semimajor axis (aodp) from input elements
val a1 = (XKE / data.xno).pow(TWO_THIRDS)
dsv.cosio = cos(data.xincl)
dsv.theta2 = dsv.cosio * dsv.cosio
x3thm1 = 3.0 * dsv.theta2 - 1
dsv.eosq = data.eccn * data.eccn
dsv.betao2 = 1.0 - dsv.eosq
dsv.betao = sqrt(dsv.betao2)
val del1 = 1.5 * CK2 * x3thm1 / (a1 * a1 * dsv.betao * dsv.betao2)
val ao = a1 * (1.0 - del1 * (0.5 * TWO_THIRDS + del1 * (1.0 + 134.0 / 81.0 * del1)))
val delo = 1.5 * CK2 * x3thm1 / (ao * ao * dsv.betao * dsv.betao2)
dsv.xnodp = data.xno / (1.0 + delo)
dsv.aodp = ao / (1.0 - delo)
// For perigee below 156 km, the values of S and QOMS2T are altered
setPerigee((dsv.aodp * (1.0 - data.eccn) - 1.0) * EARTH_RADIUS)
val pinvsq = invert(dsv.aodp * dsv.aodp * dsv.betao2 * dsv.betao2)
dsv.sing = sin(data.omegao)
dsv.cosg = cos(data.omegao)
val tsi = invert(dsv.aodp - s4)
val eta = dsv.aodp * data.eccn * tsi
val etasq = eta * eta
val eeta = data.eccn * eta
val psisq = abs(1.0 - etasq)
val coef = qoms24 * tsi.pow(4.0)
val coef1 = coef / psisq.pow(3.5)
val c2 = coef1 * dsv.xnodp * (dsv.aodp * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq))
+ 0.75 * CK2 * tsi / psisq * x3thm1 * (8.0 + 3.0 * etasq * (8.0 + etasq)))
c1 = data.bstar * c2
dsv.sinio = sin(data.xincl)
val a3ovk2 = -J3_HARMONIC / CK2
x1mth2 = 1.0 - dsv.theta2
c4 =
2 * dsv.xnodp * coef1 * dsv.aodp * dsv.betao2 * (eta * (2.0 + 0.5 * etasq) + data.eccn
* (0.5 + 2 * etasq) - 2 * CK2 * tsi / (dsv.aodp * psisq)
* (-3 * x3thm1 * (1.0 - 2 * eeta + etasq * (1.5 - 0.5 * eeta)) + (0.75 * x1mth2
* (2.0 * etasq - eeta * (1.0 + etasq)) * cos(2.0 * data.omegao))))
val theta4 = dsv.theta2 * dsv.theta2
val temp1 = 3.0 * CK2 * pinvsq * dsv.xnodp
val temp2 = temp1 * CK2 * pinvsq
val temp3 = 1.25 * CK4 * pinvsq * pinvsq * dsv.xnodp
dsv.xmdot =
dsv.xnodp + 0.5 * temp1 * dsv.betao * x3thm1 + 0.0625 * temp2 * dsv.betao * (13 - 78 * dsv.theta2 + 137 * theta4)
val x1m5th = 1.0 - 5 * dsv.theta2
dsv.omgdot =
-0.5 * temp1 * x1m5th + 0.0625 * temp2 * (7.0 - 114 * dsv.theta2 + 395 * theta4) + temp3 * (3.0 - 36 * dsv.theta2 + 49 * theta4)
val xhdot1 = -temp1 * dsv.cosio
dsv.xnodot =
xhdot1 + (0.5 * temp2 * (4.0 - 19 * dsv.theta2) + 2 * temp3 * (3.0 - 7 * dsv.theta2)) * dsv.cosio
xnodcf = 3.5 * dsv.betao2 * xhdot1 * c1
t2cof = 1.5 * c1
xlcof = 0.125 * a3ovk2 * dsv.sinio * (3.0 + 5 * dsv.cosio) / (1.0 + dsv.cosio)
aycof = 0.25 * a3ovk2 * dsv.sinio
x7thm1 = 7.0 * dsv.theta2 - 1
deep = DeepSpaceCalculator(dsv)
}
internal fun calculateSDP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(12)
val xmdf = data.xmo + dsv.xmdot * tSince
val tsq = tSince * tSince
val templ = t2cof * tsq
dsv.xll = xmdf + dsv.xnodp * templ
dsv.omgadf = data.omegao + dsv.omgdot * tSince
val xnoddf = data.xnodeo + dsv.xnodot * tSince
dsv.xnode = xnoddf + xnodcf * tsq
val tempa = 1.0 - c1 * tSince
val tempe = data.bstar * c4 * tSince
dsv.xn = dsv.xnodp
dsv.t = tSince
deep.dpsec(data)
val a = (XKE / dsv.xn).pow(TWO_THIRDS) * tempa * tempa
dsv.em -= tempe
deep.dpper()
val xl = dsv.xll + dsv.omgadf + dsv.xnode
val beta = sqrt(1.0 - dsv.em * dsv.em)
dsv.xn = XKE / a.pow(1.5)
// Long period periodics
val axn = dsv.em * cos(dsv.omgadf)
temp[0] = invert(a * beta * beta)
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = dsv.em * sin(dsv.omgadf) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - dsv.xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, a, axn, ayn)
calculatePhase(xlt, dsv.xnode, dsv.omgadf)
}
}
private fun calculatePosAndVel(temp: DoubleArray, a: Double, axn: Double, ayn: Double) {
val ecose = temp[5] + temp[6]
val esine = temp[3] - temp[4]
val elsq = axn * axn + ayn * ayn
temp[0] = 1.0 - elsq
val pl = a * temp[0]
temp[9] = a * (1.0 - ecose)
temp[1] = invert(temp[9])
temp[10] = XKE * sqrt(a) * esine * temp[1]
temp[11] = XKE * sqrt(pl) * temp[1]
temp[2] = a * temp[1]
val betal = sqrt(temp[0])
temp[3] = invert(1.0 + betal)
val cosu = temp[2] * (temp[8] - axn + ayn * esine * temp[3])
val sinu = temp[2] * (temp[7] - ayn - axn * esine * temp[3])
val u = atan2(sinu, cosu)
val sin2u = 2.0 * sinu * cosu
val cos2u = 2.0 * cosu * cosu - 1
temp[0] = invert(pl)
temp[1] = CK2 * temp[0]
temp[2] = temp[1] * temp[0]
// Update for short periodics
val rk = temp[9] * (1.0 - 1.5 * temp[2] * betal * x3thm1) + 0.5 * temp[1] * x1mth2 * cos2u
val uk = u - 0.25 * temp[2] * x7thm1 * sin2u
val xnodek = dsv.xnode + 1.5 * temp[2] * dsv.cosio * sin2u
val xinck = dsv.xinc + 1.5 * temp[2] * dsv.cosio * dsv.sinio * cos2u
val rdotk = temp[10] - dsv.xn * temp[1] * x1mth2 * sin2u
val rfdotk = temp[11] + dsv.xn * temp[1] * (x1mth2 * cos2u + 1.5 * x3thm1)
super.calculatePosAndVel(rk, uk, xnodek, xinck, rdotk, rfdotk)
}
class DeepSpaceValueObject {
var eosq = 0.0
var sinio = 0.0
var cosio = 0.0
var betao = 0.0
var aodp = 0.0
var theta2 = 0.0
var sing = 0.0
var cosg = 0.0
var betao2 = 0.0
var xmdot = 0.0
var omgdot = 0.0
var xnodot = 0.0
var xnodp = 0.0
// Used by dpsec and dpper parts of Deep()
var xll = 0.0
var omgadf = 0.0
var xnode = 0.0
var em = 0.0
var xinc = 0.0
var xn = 0.0
var t = 0.0
// Used by thetg and Deep()
var ds50 = 0.0
}
inner class DeepSpaceCalculator(private val dsv: DeepSpaceValueObject) {
private val zSinis = 3.9785416E-1
private val zSings = -9.8088458E-1
private val zNs = 1.19459E-5
private val c1ss = 2.9864797E-6
private val zEs = 1.675E-2
private val zNl = 1.5835218E-4
private val c1l = 4.7968065E-7
private val zEl = 5.490E-2
private val root22 = 1.7891679E-6
private val root32 = 3.7393792E-7
private val root44 = 7.3636953E-9
private val root52 = 1.1428639E-7
private val root54 = 2.1765803E-9
private val tHdt = 4.3752691E-3
private val q22 = 1.7891679E-6
private val q31 = 2.1460748E-6
private val q33 = 2.2123015E-7
private val g22 = 5.7686396
private val g32 = 9.5240898E-1
private val g44 = 1.8014998
private val g52 = 1.0508330
private val g54 = 4.4108898
private val thgr: Double
private val xnq: Double
private val xqncl: Double
private val omegaq: Double
private var zmol = 0.0
private var zmos = 0.0
// Many fields below cannot be final because they are iteratively refined
private var savtsn = 0.0
private var ee2 = 0.0
private var e3 = 0.0
private var xi2 = 0.0
private var xl2 = 0.0
private var xl3 = 0.0
private var xl4 = 0.0
private var xgh2 = 0.0
private var xgh3 = 0.0
private var xgh4 = 0.0
private var xh2 = 0.0
private var xh3 = 0.0
private var sse = 0.0
private var ssi = 0.0
private var ssg = 0.0
private var xi3 = 0.0
private var se2 = 0.0
private var si2 = 0.0
private var sl2 = 0.0
private var sgh2 = 0.0
private var sh2 = 0.0
private var se3 = 0.0
private var si3 = 0.0
private var sl3 = 0.0
private var sgh3 = 0.0
private var sh3 = 0.0
private var sl4 = 0.0
private var sgh4 = 0.0
private var ssl = 0.0
private var ssh = 0.0
private var d3210 = 0.0
private var d3222 = 0.0
private var d4410 = 0.0
private var d4422 = 0.0
private var d5220 = 0.0
private var d5232 = 0.0
private var d5421 = 0.0
private var d5433 = 0.0
private var del1 = 0.0
private var del2 = 0.0
private var del3 = 0.0
private var fasx2 = 0.0
private var fasx4 = 0.0
private var fasx6 = 0.0
private var xlamo = 0.0
private val xfact: Double
private var xni: Double
private var atime: Double
private val stepp: Double
private val stepn: Double
private val step2: Double
private var preep = 0.0
private var pl = 0.0
private var sghs = 0.0
private var xli: Double
private var d2201 = 0.0
private var d2211 = 0.0
private var sghl = 0.0
private var sh1 = 0.0
private var pinc = 0.0
private var pe = 0.0
private var shs = 0.0
private var zsingl = 0.0
private var zcosgl = 0.0
private var zsinhl = 0.0
private var zcoshl = 0.0
private var zsinil = 0.0
private var zcosil = 0.0
private var a1 = 0.0
private var a2 = 0.0
private var a3 = 0.0
private var a4 = 0.0
private var a5 = 0.0
private var a6 = 0.0
private var a7 = 0.0
private var a8 = 0.0
private var a9 = 0.0
private var a10 = 0.0
private var ainv2 = 0.0
private var alfdp = 0.0
private val aqnv: Double
private var sgh = 0.0
private var sini2 = 0.0
private var sinis = 0.0
private var sinok = 0.0
private var sh = 0.0
private var si = 0.0
private var sil = 0.0
private val day: Double
private var betdp = 0.0
private var dalf = 0.0
private var bfact = 0.0
private var c = 0.0
private var cc = 0.0
private var cosis = 0.0
private var cosok = 0.0
private val cosq: Double
private var ctem = 0.0
private var f322 = 0.0
private var zx = 0.0
private var zy = 0.0
private var dbet = 0.0
private var dls = 0.0
private var eoc = 0.0
private val eq: Double
private var f2 = 0.0
private var f220 = 0.0
private var f221 = 0.0
private var f3 = 0.0
private var f311 = 0.0
private var f321 = 0.0
private var xnoh = 0.0
private var f330 = 0.0
private var f441 = 0.0
private var f442 = 0.0
private var f522 = 0.0
private var f523 = 0.0
private var f542 = 0.0
private var f543 = 0.0
private var g200 = 0.0
private var g201 = 0.0
private var g211 = 0.0
private var pgh = 0.0
private var ph = 0.0
private var s1 = 0.0
private var s2 = 0.0
private var s3 = 0.0
private var s4 = 0.0
private var s5 = 0.0
private var s6 = 0.0
private var s7 = 0.0
private var se = 0.0
private var sel = 0.0
private var ses = 0.0
private var xls = 0.0
private var g300 = 0.0
private var g310 = 0.0
private var g322 = 0.0
private var g410 = 0.0
private var g422 = 0.0
private var g520 = 0.0
private var g521 = 0.0
private var g532 = 0.0
private var g533 = 0.0
private var gam = 0.0
private val sinq: Double
private var sinzf = 0.0
private var sis = 0.0
private var sl = 0.0
private var sll = 0.0
private var sls = 0.0
private var stem = 0.0
private var temp = 0.0
private var temp1 = 0.0
private var x1 = 0.0
private var x2 = 0.0
private var x2li = 0.0
private var x2omi = 0.0
private var x3 = 0.0
private var x4 = 0.0
private var x5 = 0.0
private var x6 = 0.0
private var x7 = 0.0
private var x8 = 0.0
private var xl = 0.0
private var xldot = 0.0
private val xmao: Double
private var xnddt = 0.0
private var xndot = 0.0
private var xno2 = 0.0
private var xnodce = 0.0
private var xnoi = 0.0
private var xomi = 0.0
private val xpidot: Double
private var z1 = 0.0
private var z11 = 0.0
private var z12 = 0.0
private var z13 = 0.0
private var z2 = 0.0
private var z21 = 0.0
private var z22 = 0.0
private var z23 = 0.0
private var z3 = 0.0
private var z31 = 0.0
private var z32 = 0.0
private var z33 = 0.0
private var ze = 0.0
private var zf = 0.0
private var zm = 0.0
private var zn = 0.0
private var zsing = 0.0
private var zsinh = 0.0
private var zsini = 0.0
private var zcosg = 0.0
private var zcosh = 0.0
private var zcosi = 0.0
private var delt = 0.0
private var ft = 0.0
private var resonance: Boolean
private var synchronous: Boolean
private var doLoop = false
private var epochRestart = false
init {
thgr = thetaG(data.epoch)
eq = data.eccn
xnq = dsv.xnodp
aqnv = invert(dsv.aodp)
xqncl = data.xincl
xmao = data.xmo
xpidot = dsv.omgdot + dsv.xnodot
sinq = sin(data.xnodeo)
cosq = cos(data.xnodeo)
omegaq = data.omegao
// Initialize lunar solar terms, days since 1900 Jan 0.5
day = dsv.ds50 + 18261.5
if (abs(day - preep) > 1.0E-6) {
preep = day
xnodce = 4.5236020 - 9.2422029E-4 * day
stem = sin(xnodce)
ctem = cos(xnodce)
zcosil = 0.91375164 - 0.03568096 * ctem
zsinil = sqrt(1.0 - zcosil * zcosil)
zsinhl = 0.089683511 * stem / zsinil
zcoshl = sqrt(1.0 - zsinhl * zsinhl)
c = 4.7199672 + 0.22997150 * day
gam = 5.8351514 + 0.0019443680 * day
zmol = mod2PI(c - gam)
zx = 0.39785416 * stem / zsinil
zy = zcoshl * ctem + 0.91744867 * zsinhl * stem
zx = atan2(zx, zy)
zx = gam + zx - xnodce
zcosgl = cos(zx)
zsingl = sin(zx)
zmos = mod2PI(6.2565837 + 0.017201977 * day)
} else {
zmol = 0.0
zmos = 0.0
}
doSolarTerms()
// Geopotential resonance initialization for 12 hour orbits
resonance = false
synchronous = false
if (!(xnq < 0.0052359877 && xnq > 0.0034906585)) {
if (xnq !in 0.00826..0.00924)
if (eq < 0.5)
// calculateResonance
resonance = true
eoc = eq * dsv.eosq
g201 = -0.306 - (eq - 0.64) * 0.440
if (eq <= 0.65) {
g211 = 3.616 - 13.247 * eq + 16.290 * dsv.eosq
g310 = -19.302 + 117.390 * eq - 228.419 * dsv.eosq + 156.591 * eoc
g322 = -18.9068 + 109.7927 * eq - 214.6334 * dsv.eosq + 146.5816 * eoc
g410 = -41.122 + 242.694 * eq - 471.094 * dsv.eosq + 313.953 * eoc
g422 = -146.407 + 841.880 * eq - 1629.014 * dsv.eosq + 1083.435 * eoc
g520 = -532.114 + 3017.977 * eq - 5740 * dsv.eosq + 3708.276 * eoc
} else {
g211 = -72.099 + 331.819 * eq - 508.738 * dsv.eosq + 266.724 * eoc
g310 = -346.844 + 1582.851 * eq - 2415.925 * dsv.eosq + 1246.113 * eoc
g322 = -342.585 + 1554.908 * eq - 2366.899 * dsv.eosq + 1215.972 * eoc
g410 = -1052.797 + 4758.686 * eq - 7193.992 * dsv.eosq + 3651.957 * eoc
g422 = -3581.69 + 16178.11 * eq - 24462.77 * dsv.eosq + 12422.52 * eoc
g520 =
if (eq <= 0.715) 1464.74 - 4664.75 * eq + 3763.64 * dsv.eosq
else -5149.66 + 29936.92 * eq - 54087.36 * dsv.eosq + 31324.56 * eoc
}
if (eq < 0.7) {
g533 = -919.2277 + 4988.61 * eq - 9064.77 * dsv.eosq + 5542.21 * eoc
g521 = -822.71072 + 4568.6173 * eq - 8491.4146 * dsv.eosq + 5337.524 * eoc
g532 = -853.666 + 4690.25 * eq - 8624.77 * dsv.eosq + 5341.4 * eoc
} else {
g533 = -37995.78 + 161616.52 * eq - 229838.2 * dsv.eosq + 109377.94 * eoc
g521 = -51752.104 + 218913.95 * eq - 309468.16 * dsv.eosq + 146349.42 * eoc
g532 = -40023.88 + 170470.89 * eq - 242699.48 * dsv.eosq + 115605.82 * eoc
}
sini2 = dsv.sinio * dsv.sinio
f220 = 0.75 * (1.0 + 2 * dsv.cosio + dsv.theta2)
f221 = 1.5 * sini2
f321 = 1.875 * dsv.sinio * (1.0 - 2 * dsv.cosio - 3.0 * dsv.theta2)
f322 = -1.875 * dsv.sinio * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2)
f441 = 35 * sini2 * f220
f442 = 39.3750 * sini2 * sini2
f522 =
9.84375 * dsv.sinio * (sini2 * (1.0 - 2 * dsv.cosio - 5 * dsv.theta2) + 0.33333333 * (-2 + 4 * dsv.cosio + 6 * dsv.theta2))
f523 =
dsv.sinio * (4.92187512 * sini2 * (-2 - 4 * dsv.cosio + 10 * dsv.theta2) + 6.56250012 * (1.0 + 2 * dsv.cosio - 3.0 * dsv.theta2))
f542 =
29.53125 * dsv.sinio * (2.0 - 8 * dsv.cosio + dsv.theta2 * (-12 + 8 * dsv.cosio + 10 * dsv.theta2))
f543 =
29.53125 * dsv.sinio * (-2 - 8 * dsv.cosio + dsv.theta2 * (12 + 8 * dsv.cosio - 10 * dsv.theta2))
xno2 = xnq * xnq
ainv2 = aqnv * aqnv
temp1 = 3.0 * xno2 * ainv2
temp = temp1 * root22
d2201 = temp * f220 * g201
d2211 = temp * f221 * g211
temp1 *= aqnv
temp = temp1 * root32
d3210 = temp * f321 * g310
d3222 = temp * f322 * g322
temp1 *= aqnv
temp = 2.0 * temp1 * root44
d4410 = temp * f441 * g410
d4422 = temp * f442 * g422
temp1 *= aqnv
temp = temp1 * root52
d5220 = temp * f522 * g520
d5232 = temp * f523 * g532
temp = 2.0 * temp1 * root54
d5421 = temp * f542 * g521
d5433 = temp * f543 * g533
xlamo = xmao + data.xnodeo + data.xnodeo - thgr - thgr
bfact = dsv.xmdot + dsv.xnodot + dsv.xnodot - tHdt - tHdt
bfact += ssl + ssh + ssh
} else {
// Init synchronous resonance terms
resonance = true
synchronous = true
g200 = 1.0 + dsv.eosq * (-2.5 + 0.8125 * dsv.eosq)
g310 = 1.0 + 2 * dsv.eosq
g300 = 1.0 + dsv.eosq * (-6 + 6.60937 * dsv.eosq)
f220 = 0.75 * (1.0 + dsv.cosio) * (1.0 + dsv.cosio)
f311 =
0.9375 * dsv.sinio * dsv.sinio * (1.0 + 3.0 * dsv.cosio) - 0.75 * (1.0 + dsv.cosio)
f330 = 1.0 + dsv.cosio
f330 *= 1.875 * f330 * f330
del1 = 3.0 * xnq * xnq * aqnv * aqnv
del2 = 2.0 * del1 * f220 * g200 * q22
del3 = 3.0 * del1 * f330 * g300 * q33 * aqnv
del1 *= f311 * g310 * q31 * aqnv
fasx2 = 0.13130908
fasx4 = 2.8843198
fasx6 = 0.37448087
xlamo = xmao + data.xnodeo + data.omegao - thgr
bfact = dsv.xmdot + xpidot - tHdt
bfact += ssl + ssg + ssh
}
xfact = bfact - xnq
// Init integrator
xli = xlamo
xni = xnq
atime = 0.0
stepp = 720.0
stepn = -720.0
step2 = 259200.0
}
// Entrance for lunar-solar periodics
fun dpper() {
sinis = sin(dsv.xinc)
cosis = cos(dsv.xinc)
if (abs(savtsn - dsv.t) >= 30) {
savtsn = dsv.t
zm = zmos + zNs * dsv.t
zf = zm + 2 * zEs * sin(zm)
sinzf = sin(zf)
f2 = 0.5 * sinzf * sinzf - 0.25
f3 = -0.5 * sinzf * cos(zf)
ses = se2 * f2 + se3 * f3
sis = si2 * f2 + si3 * f3
sls = sl2 * f2 + sl3 * f3 + sl4 * sinzf
sghs = sgh2 * f2 + sgh3 * f3 + sgh4 * sinzf
shs = sh2 * f2 + sh3 * f3
zm = zmol + zNl * dsv.t
zf = zm + 2 * zEl * sin(zm)
sinzf = sin(zf)
f2 = 0.5 * sinzf * sinzf - 0.25
f3 = -0.5 * sinzf * cos(zf)
sel = ee2 * f2 + e3 * f3
sil = xi2 * f2 + xi3 * f3
sll = xl2 * f2 + xl3 * f3 + xl4 * sinzf
sghl = xgh2 * f2 + xgh3 * f3 + xgh4 * sinzf
sh1 = xh2 * f2 + xh3 * f3
pe = ses + sel
pinc = sis + sil
pl = sls + sll
}
pgh = sghs + sghl
ph = shs + sh1
dsv.xinc += pinc
dsv.em += pe
if (xqncl >= 0.2) {
/* Apply periodics directly */
ph /= dsv.sinio
pgh -= dsv.cosio * ph
dsv.omgadf += pgh
dsv.xnode += ph
dsv.xll += pl
} else {
applyPeriodics()
// This is a patch to Lyddane modification suggested by Rob Matson
if (abs(xnoh - dsv.xnode) > PI) {
if (dsv.xnode < xnoh) dsv.xnode += TWO_PI else dsv.xnode -= TWO_PI
}
dsv.xll += pl
dsv.omgadf = xls - dsv.xll - cos(dsv.xinc) * dsv.xnode
}
}
// Entrance for deep space secular effects
fun dpsec(params: OrbitalData) {
dsv.xll += ssl * dsv.t
dsv.omgadf += ssg * dsv.t
dsv.xnode += ssh * dsv.t
dsv.em = params.eccn + sse * dsv.t
dsv.xinc = params.xincl + ssi * dsv.t
if (dsv.xinc < 0) {
dsv.xinc = -dsv.xinc
dsv.xnode += PI
dsv.omgadf -= PI
}
if (!resonance) return
do processEpochRestartLoop() while (doLoop && epochRestart)
dsv.xn = xni + xndot * ft + xnddt * ft * ft * 0.5
xl = xli + xldot * ft + xndot * ft * ft * 0.5
temp = -dsv.xnode + thgr + dsv.t * tHdt
if (synchronous) dsv.xll = xl - dsv.omgadf + temp else dsv.xll = xl + temp + temp
}
private fun doSolarTerms() {
savtsn = 1E20
zcosg = 1.945905E-1
zsing = zSings
zcosi = 9.1744867E-1
zsini = zSinis
zcosh = cosq
zsinh = sinq
cc = c1ss
zn = zNs
ze = zEs
xnoi = invert(xnq)
calculateSolarTerms()
calculateLunarTerms()
calculateSolarTerms() // Solar terms done again after Lunar terms are done
sse += se
ssi += si
ssl += sl
ssg = ssg + sgh - dsv.cosio / dsv.sinio * sh
ssh += sh / dsv.sinio
}
private fun calculateLunarTerms() {
sse = se
ssi = si
ssl = sl
ssh = sh / dsv.sinio
ssg = sgh - dsv.cosio * ssh
se2 = ee2
si2 = xi2
sl2 = xl2
sgh2 = xgh2
sh2 = xh2
se3 = e3
si3 = xi3
sl3 = xl3
sgh3 = xgh3
sh3 = xh3
sl4 = xl4
sgh4 = xgh4
zcosg = zcosgl
zsing = zsingl
zcosi = zcosil
zsini = zsinil
zcosh = zcoshl * cosq + zsinhl * sinq
zsinh = sinq * zcoshl - cosq * zsinhl
zn = zNl
cc = c1l
ze = zEl
}
private fun calculateSolarTerms() {
a1 = zcosg * zcosh + zsing * zcosi * zsinh
a3 = -zsing * zcosh + zcosg * zcosi * zsinh
a7 = -zcosg * zsinh + zsing * zcosi * zcosh
a8 = zsing * zsini
a9 = zsing * zsinh + zcosg * zcosi * zcosh
a10 = zcosg * zsini
a2 = dsv.cosio * a7 + dsv.sinio * a8
a4 = dsv.cosio * a9 + dsv.sinio * a10
a5 = -dsv.sinio * a7 + dsv.cosio * a8
a6 = -dsv.sinio * a9 + dsv.cosio * a10
x1 = a1 * dsv.cosg + a2 * dsv.sing
x2 = a3 * dsv.cosg + a4 * dsv.sing
x3 = -a1 * dsv.sing + a2 * dsv.cosg
x4 = -a3 * dsv.sing + a4 * dsv.cosg
x5 = a5 * dsv.sing
x6 = a6 * dsv.sing
x7 = a5 * dsv.cosg
x8 = a6 * dsv.cosg
z31 = 12 * x1 * x1 - 3.0 * x3 * x3
z32 = 24 * x1 * x2 - 6 * x3 * x4
z33 = 12 * x2 * x2 - 3.0 * x4 * x4
z1 = 3.0 * (a1 * a1 + a2 * a2) + z31 * dsv.eosq
z2 = 6.0 * (a1 * a3 + a2 * a4) + z32 * dsv.eosq
z3 = 3.0 * (a3 * a3 + a4 * a4) + z33 * dsv.eosq
z11 = -6 * a1 * a5 + dsv.eosq * (-24 * x1 * x7 - 6 * x3 * x5)
z12 =
-6 * (a1 * a6 + a3 * a5) + dsv.eosq * (-24 * (x2 * x7 + x1 * x8) - 6 * (x3 * x6 + x4 * x5))
z13 = -6 * a3 * a6 + dsv.eosq * (-24 * x2 * x8 - 6 * x4 * x6)
z21 = 6.0 * a2 * a5 + dsv.eosq * (24 * x1 * x5 - 6 * x3 * x7)
z22 =
6.0 * (a4 * a5 + a2 * a6) + dsv.eosq * (24 * (x2 * x5 + x1 * x6) - 6 * (x4 * x7 + x3 * x8))
z23 = 6.0 * a4 * a6 + dsv.eosq * (24 * x2 * x6 - 6 * x4 * x8)
z1 += z1 + dsv.betao2 * z31
z2 += z2 + dsv.betao2 * z32
z3 += z3 + dsv.betao2 * z33
s3 = cc * xnoi
s2 = -0.5 * s3 / dsv.betao
s4 = s3 * dsv.betao
s1 = -15 * eq * s4
s5 = x1 * x3 + x2 * x4
s6 = x2 * x3 + x1 * x4
s7 = x2 * x4 - x1 * x3
se = s1 * zn * s5
si = s2 * zn * (z11 + z13)
sl = -zn * s3 * (z1 + z3 - 14 - 6 * dsv.eosq)
sgh = s4 * zn * (z31 + z33 - 6)
sh = -zn * s2 * (z21 + z23)
if (xqncl < 5.2359877E-2) sh = 0.0
ee2 = 2.0 * s1 * s6
e3 = 2.0 * s1 * s7
xi2 = 2.0 * s2 * z12
xi3 = 2.0 * s2 * (z13 - z11)
xl2 = -2 * s3 * z2
xl3 = -2 * s3 * (z3 - z1)
xl4 = -2 * s3 * (-21 - 9 * dsv.eosq) * ze
xgh2 = 2.0 * s4 * z32
xgh3 = 2.0 * s4 * (z33 - z31)
xgh4 = -18 * s4 * ze
xh2 = -2 * s2 * z22
xh3 = -2 * s2 * (z23 - z21)
}
private fun processEpochRestartLoop() {
if (atime == 0.0 || dsv.t >= 0 && atime < 0 || dsv.t < 0 && atime >= 0) {
calculateDelta()
atime = 0.0
xni = xnq
xli = xlamo
} else if (abs(dsv.t) >= abs(atime)) calculateDelta()
processNotEpochRestartLoop()
}
private fun calculateDelta() {
delt = if (dsv.t < 0) stepn else stepp
}
private fun processNotEpochRestartLoop() {
do {
if (abs(dsv.t - atime) >= stepp) {
doLoop = true
epochRestart = false
} else {
ft = dsv.t - atime
doLoop = false
}
if (abs(dsv.t) < abs(atime)) {
delt = if (dsv.t >= 0) stepn else stepp
doLoop = doLoop or epochRestart
}
if (synchronous) {
xndot = del1 * sin(xli - fasx2) + del2 * sin(2.0 * (xli - fasx4)) +
del3 * sin(3.0 * (xli - fasx6))
xnddt = del1 * cos(xli - fasx2) + 2 * del2 * cos(2.0 * (xli - fasx4)) +
3.0 * del3 * cos(3.0 * (xli - fasx6))
} else {
xomi = omegaq + dsv.omgdot * atime
x2omi = xomi + xomi
x2li = xli + xli
xndot =
d2201 * sin(x2omi + xli - g22) + d2211 * sin(xli - g22) + (d3210
* sin(xomi + xli - g32)) + d3222 * sin(-xomi + xli - g32) + (d4410
* sin(x2omi + x2li - g44)) + d4422 * sin(x2li - g44) + (d5220
* sin(xomi + xli - g52)) + d5232 * sin(-xomi + xli - g52) + (d5421
* sin(xomi + x2li - g54)) + d5433 * sin(-xomi + x2li - g54)
xnddt =
d2201 * cos(x2omi + xli - g22) + d2211 * cos(xli - g22) + (d3210
* cos(xomi + xli - g32)) + d3222 * cos(-xomi + xli - g32) + (d5220
* cos(xomi + xli - g52)) + d5232 * cos(-xomi + xli - g52) + (2
* (d4410 * cos(x2omi + x2li - g44) + d4422 * cos(x2li - g44) + (d5421
* cos(xomi + x2li - g54)) + d5433 * cos(-xomi + x2li - g54)))
}
xldot = xni + xfact
xnddt *= xldot
if (doLoop) {
xli += xldot * delt + xndot * step2
xni += xndot * delt + xnddt * step2
atime += delt
}
} while (doLoop && !epochRestart)
}
// Apply periodics with Lyddane modification
private fun applyPeriodics() {
sinok = sin(dsv.xnode)
cosok = cos(dsv.xnode)
alfdp = sinis * sinok
betdp = sinis * cosok
dalf = ph * cosok + pinc * cosis * sinok
dbet = -ph * sinok + pinc * cosis * cosok
alfdp += dalf
betdp += dbet
dsv.xnode = mod2PI(dsv.xnode)
xls = dsv.xll + dsv.omgadf + cosis * dsv.xnode
dls = pl + pgh - pinc * dsv.xnode * sinis
xls += dls
xnoh = dsv.xnode
dsv.xnode = atan2(alfdp, betdp)
}
// Calculates the Greenwich Mean Sidereal Time for an epoch, valid 1957 through 2056
private fun thetaG(epoch: Double): Double {
var year = floor(epoch * 1E-3)
var dayOfYear = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
val dayFloor = floor(dayOfYear)
val dayFraction = dayOfYear - dayFloor
dayOfYear = dayFloor
val jd = julianDateOfYear(year) + dayOfYear
dsv.ds50 = jd - 2433281.5 + dayFraction
return mod2PI(6.3003880987 * dsv.ds50 + 1.72944494)
}
}
}
@@ -0,0 +1,26 @@
/*
* 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
data class GeoPos(
val latitude: Double,
val longitude: Double,
val altitude: Double = 0.0,
val qthLocator: String = "null",
val timestamp: Long = 0L
)
@@ -0,0 +1,226 @@
/*
* 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 kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
class NearEarthObject(data: OrbitalData) : OrbitalObject(data) {
private val aodp: Double
private val aycof: Double
private val c1: Double
private val c4: Double
private val c5: Double
private val cosio: Double
private var d2 = 0.0
private var d3 = 0.0
private var d4 = 0.0
private val delmo: Double
private val omgcof: Double
private val eta: Double
private val omgdot: Double
private val sinio: Double
private val xnodp: Double
private val sinmo: Double
private val t2cof: Double
private var t3cof = 0.0
private var t4cof = 0.0
private var t5cof = 0.0
private val x1mth2: Double
private val x3thm1: Double
private val x7thm1: Double
private val xmcof: Double
private val xmdot: Double
private val xnodcf: Double
private val xnodot: Double
private val xlcof: Double
private val sgp4Simple: Boolean
init {
// Recover original mean motion (xnodp) and semimajor axis (aodp) from input elements
val a1 = (XKE / data.xno).pow(TWO_THIRDS)
cosio = cos(data.xincl)
val theta2 = sqr(cosio)
x3thm1 = 3.0 * theta2 - 1.0
val eo = data.eccn
val eosq = sqr(eo)
val betao2 = 1.0 - eosq
val betao = sqrt(betao2)
val del1 = 1.5 * CK2 * x3thm1 / (sqr(a1) * betao * betao2)
val ao = a1 * (1.0 - del1 * (0.5 * TWO_THIRDS + del1 * (1.0 + 134.0 / 81.0 * del1)))
val delo = 1.5 * CK2 * x3thm1 / (sqr(ao) * betao * betao2)
xnodp = data.xno / (1.0 + delo)
aodp = ao / (1.0 - delo)
// For perigee less than 220 kilometers, the "simple" flag is set
sgp4Simple = aodp * (1.0 - eo) < 220 / EARTH_RADIUS + 1.0
// For perigees below 156 km, the values of S and QOMS2T are altered
setPerigee((aodp * (1.0 - eo) - 1.0) * EARTH_RADIUS)
val pinvsq = invert(sqr(aodp) * sqr(betao2))
val tsi = invert(aodp - s4)
eta = aodp * eo * tsi
val etasq = eta * eta
val eeta = eo * eta
val psisq = abs(1.0 - etasq)
val coef = qoms24 * tsi.pow(4.0)
val coef1 = coef / psisq.pow(3.5)
val bstar = data.bstar
val c2 = coef1 * xnodp * (aodp * (1.0 + 1.5 * etasq + eeta * (4.0 + etasq)) + 0.75
* CK2 * tsi / psisq * x3thm1 * (8.0 + 3.0 * etasq * (8.0 + etasq)))
c1 = bstar * c2
sinio = sin(data.xincl)
val a3ovk2 = -J3_HARMONIC / CK2
val c3 = coef * tsi * a3ovk2 * xnodp * sinio / eo
x1mth2 = 1.0 - theta2
val omegao = data.omegao
c4 = 2 * xnodp * coef1 * aodp * betao2 * (eta * (2.0 + 0.5 * etasq) + eo * (0.5 + 2 * etasq)
- 2 * CK2 * tsi / (aodp * psisq) * (-3 * x3thm1 * (1.0 - 2 * eeta + etasq
* (1.5 - 0.5 * eeta)) + 0.75 * x1mth2 * (2.0 * etasq - eeta * (1.0 + etasq))
* cos(2.0 * omegao)))
c5 = 2.0 * coef1 * aodp * betao2 * (1.0 + 2.75 * (etasq + eeta) + eeta * etasq)
val theta4 = sqr(theta2)
val temp1 = 3.0 * CK2 * pinvsq * xnodp
val temp2 = temp1 * CK2 * pinvsq
val temp3 = 1.25 * CK4 * pinvsq * pinvsq * xnodp
xmdot =
xnodp + 0.5 * temp1 * betao * x3thm1 + (0.0625 * temp2 * betao * (13.0 - 78.0 * theta2 + 137.0 * theta4))
val x1m5th = 1.0 - 5.0 * theta2
omgdot =
-0.5 * temp1 * x1m5th + 0.0625 * temp2 * (7.0 - 114.0 * theta2 + 395.0 * theta4) + temp3 * (3.0 - 36.0 * theta2 + 49.0 * theta4)
val xhdot1 = -temp1 * cosio
xnodot =
xhdot1 + (0.5 * temp2 * (4.0 - 19.0 * theta2) + 2.0 * temp3 * (3.0 - 7.0 * theta2)) * cosio
omgcof = bstar * c3 * cos(omegao)
xmcof = -TWO_THIRDS * coef * bstar / eeta
xnodcf = 3.5 * betao2 * xhdot1 * c1
t2cof = 1.5 * c1
xlcof = 0.125 * a3ovk2 * sinio * (3.0 + 5 * cosio) / (1.0 + cosio)
aycof = 0.25 * a3ovk2 * sinio
val xmo = data.xmo
delmo = (1.0 + eta * cos(xmo)).pow(3.0)
sinmo = sin(xmo)
x7thm1 = 7.0 * theta2 - 1
if (!sgp4Simple) {
val c1sq = sqr(c1)
d2 = 4.0 * aodp * tsi * c1sq
val temp = d2 * tsi * c1 / 3.0
d3 = (17 * aodp + s4) * temp
d4 = 0.5 * temp * aodp * tsi * (221 * aodp + 31 * s4) * c1
t3cof = d2 + 2 * c1sq
t4cof = 0.25 * (3.0 * d3 + c1 * (12 * d2 + 10 * c1sq))
t5cof = 0.2 * (3.0 * d4 + 12 * c1 * d3 + 6 * d2 * d2 + 15 * c1sq * (2.0 * d2 + c1sq))
} else {
d2 = 0.0
d3 = 0.0
d4 = 0.0
t3cof = 0.0
t4cof = 0.0
t5cof = 0.0
}
}
internal fun calculateSGP4(tSince: Double) {
synchronized(this) {
val temp = DoubleArray(9)
val xmdf = data.xmo + xmdot * tSince
val omgadf = data.omegao + omgdot * tSince
val xnoddf = data.xnodeo + xnodot * tSince
var omega = omgadf
var xmp = xmdf
val tsq = sqr(tSince)
val xnode = xnoddf + xnodcf * tsq
val bstar = data.bstar
var tempa = 1.0 - c1 * tSince
var tempe = bstar * c4 * tSince
var templ = t2cof * tsq
if (!sgp4Simple) {
val delomg = omgcof * tSince
val delm = xmcof * ((1.0 + eta * cos(xmdf)).pow(3.0) - delmo)
temp[0] = delomg + delm
xmp = xmdf + temp[0]
omega = omgadf - temp[0]
val tcube = tsq * tSince
val tfour = tSince * tcube
tempa = tempa - d2 * tsq - d3 * tcube - d4 * tfour
tempe += bstar * c5 * (sin(xmp) - sinmo)
templ += t3cof * tcube + tfour * (t4cof + tSince * t5cof)
}
val a = aodp * tempa.pow(2.0)
val eo = data.eccn
val e = eo - tempe
val xl = xmp + omega + xnode + xnodp * templ
val beta = sqrt(1.0 - e * e)
val xn = XKE / a.pow(1.5)
// Long period periodics
val axn = e * cos(omega)
temp[0] = invert(a * sqr(beta))
val xll = temp[0] * xlcof * axn
val aynl = temp[0] * aycof
val xlt = xl + xll
val ayn = e * sin(omega) + aynl
// Solve Kepler's equation
val capu = mod2PI(xlt - xnode)
temp[2] = capu
converge(temp, axn, ayn, capu)
calculatePosAndVel(temp, xnode, a, xn, axn, ayn)
calculatePhase(xlt, xnode, omgadf)
}
}
private fun calculatePosAndVel(
temp: DoubleArray, xnode: Double, a: Double,
xn: Double, axn: Double, ayn: Double
) {
val ecose = temp[5] + temp[6]
val esine = temp[3] - temp[4]
val elsq = sqr(axn) + sqr(ayn)
temp[0] = 1.0 - elsq
val pl = a * temp[0]
val r = a * (1.0 - ecose)
temp[1] = invert(r)
val rdot = XKE * sqrt(a) * esine * temp[1]
val rfdot = XKE * sqrt(pl) * temp[1]
temp[2] = a * temp[1]
val betal = sqrt(temp[0])
temp[3] = invert(1.0 + betal)
val cosu = temp[2] * (temp[8] - axn + ayn * esine * temp[3])
val sinu = temp[2] * (temp[7] - ayn - axn * esine * temp[3])
val u = atan2(sinu, cosu)
val sin2u = 2.0 * sinu * cosu
val cos2u = 2.0 * cosu * cosu - 1
temp[0] = invert(pl)
temp[1] = CK2 * temp[0]
temp[2] = temp[1] * temp[0]
// Update for short periodics
val rk = r * (1.0 - 1.5 * temp[2] * betal * x3thm1) + 0.5 * temp[1] * x1mth2 * cos2u
val uk = u - 0.25 * temp[2] * x7thm1 * sin2u
val xnodek = xnode + 1.5 * temp[2] * cosio * sin2u
val xinck = data.xincl + 1.5 * temp[2] * cosio * sinio * cos2u
val rdotk = rdot - xn * temp[1] * x1mth2 * sin2u
val rfdotk = rfdot + xn * temp[1] * (x1mth2 * cos2u + 1.5 * x3thm1)
super.calculatePosAndVel(rk, uk, xnodek, xinck, rdotk, rfdotk)
}
}
@@ -0,0 +1,40 @@
/*
* 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
data class OrbitalData(
val name: String,
val epoch: Double,
val meanmo: Double,
val eccn: Double,
val incl: Double,
val raan: Double,
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
) {
val xincl: Double = incl * DEG2RAD
val xnodeo: Double = raan * DEG2RAD
val omegao: Double = argper * DEG2RAD
val xmo: Double = meanan * DEG2RAD
val xno: Double = meanmo * TWO_PI / MIN_PER_DAY
val orbitalPeriod: Double = MIN_PER_DAY / meanmo
val isDeepSpace: Boolean = orbitalPeriod >= 225.0 // NearEarth (period < 225 min) or DeepSpace (period >= 225 min)
fun getObject(): OrbitalObject = if (isDeepSpace) DeepSpaceObject(this) else NearEarthObject(this)
}
@@ -0,0 +1,423 @@
/*
* 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 kotlin.math.abs
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
abstract class OrbitalObject(val data: OrbitalData) {
private val position = Vector4()
private val velocity = Vector4()
private var orbitalPos = OrbitalPos()
private var eclipseDepth = 0.0
private var gsPosTheta = 0.0
private var julUTC = 0.0
private var perigee = 0.0
var qoms24 = 0.0
var s4 = 0.0
fun willBeSeen(pos: GeoPos): Boolean {
return if (data.meanmo < 1e-8) false
else {
val sma = 331.25 * exp(ln(MIN_PER_DAY / data.meanmo) * (2.0 / 3.0))
val apogee = sma * (1.0 + data.eccn) - EARTH_RADIUS
var lin = data.incl
if (lin >= 90.0) lin = 180.0 - lin
acos(EARTH_RADIUS / (apogee + EARTH_RADIUS)) + lin * DEG2RAD > abs(pos.latitude * DEG2RAD)
}
}
fun getPosition(pos: GeoPos, time: Long): OrbitalPos {
orbitalPos = OrbitalPos()
// Date/time at which the position and velocity were calculated
julUTC = calcCurrentDaynum(time) + 2444238.5
// Convert satellite's epoch time to Julian and calculate time since epoch in minutes
val julEpoch = juliandDateOfEpoch(data.epoch)
val tsince = (julUTC - julEpoch) * MIN_PER_DAY
calculateSDP4orSGP4(tsince)
// Scale position and velocity vectors to km and km/sec
convertSatState(position, velocity)
// Calculate velocity of satellite
magnitude(velocity)
val squintVector = Vector4()
// Angles in rads, dist in km, vel in km/S. Calculate sat Az, El, Range and Range-rate.
calculateObs(julUTC, position, velocity, pos, squintVector)
calculateLatLonAlt(julUTC)
orbitalPos.time = time
orbitalPos.eclipsed = isEclipsed()
orbitalPos.eclipseDepth = eclipseDepth
return orbitalPos
}
private fun calcCurrentDaynum(now: Long): Double {
val then = 315446400000 // time in millis on 31Dec79 00:00:00 UTC (daynum 0)
return (now - then) / 1000.0 / 60.0 / 60.0 / 24.0
}
private fun juliandDateOfEpoch(epoch: Double): Double {
var year = floor(epoch * 1E-3)
val day = (epoch * 1E-3 - year) * 1000.0
year = if (year < 57) year + 2000 else year + 1900
return julianDateOfYear(year) + day
}
internal fun julianDateOfYear(theYear: Double): Double {
val aYear = theYear - 1
var i = floor(aYear / 100).toLong()
val a = i
i = a / 4
val b = 2 - a + i
i = floor(365.25 * aYear).toLong()
i += (30.6001 * 14).toLong()
return i + 1720994.5 + b
}
private fun calculateSDP4orSGP4(tsince: Double) {
if (data.isDeepSpace) (this as DeepSpaceObject).calculateSDP4(tsince)
else (this as NearEarthObject).calculateSGP4(tsince)
}
// Converts the sat position and velocity vectors to km and km/sec
private fun convertSatState(pos: Vector4, vel: Vector4) {
scaleVector(EARTH_RADIUS, pos)
scaleVector(EARTH_RADIUS * MIN_PER_DAY / SEC_PER_DAY, vel)
}
// Calculates the topocentric coordinates of the object with ECI pos and vel at time
private fun calculateObs(
julianUTC: Double,
positionVector: Vector4,
velocityVector: Vector4,
gsPos: GeoPos,
squintVector: Vector4
) {
val obsPos = Vector4()
val obsVel = Vector4()
val range = Vector4()
val rgvel = Vector4()
calculateUserPosVel(julianUTC, gsPos, obsPos, obsVel)
range.setXYZ(
positionVector.x - obsPos.x,
positionVector.y - obsPos.y,
positionVector.z - obsPos.z
)
// Save these values globally for calculating squint angles later
squintVector.setXYZ(range.x, range.y, range.z)
rgvel.setXYZ(
velocityVector.x - obsVel.x,
velocityVector.y - obsVel.y,
velocityVector.z - obsVel.z
)
magnitude(range)
val sinLat = sin(DEG2RAD * gsPos.latitude)
val cosLat = cos(DEG2RAD * gsPos.latitude)
val sinTheta = sin(gsPosTheta)
val cosTheta = cos(gsPosTheta)
val topS = sinLat * cosTheta * range.x + sinLat * sinTheta * range.y - cosLat * range.z
val topE = -sinTheta * range.x + cosTheta * range.y
val topZ = cosLat * cosTheta * range.x + cosLat * sinTheta * range.y + sinLat * range.z
var azim = atan(-topE / topS)
if (topS > 0.0) azim += PI
if (azim < 0.0) azim += TWO_PI
orbitalPos.azimuth = azim
orbitalPos.elevation = asin(topZ / range.w)
orbitalPos.distance = range.w
orbitalPos.distanceRate = dot(range, rgvel) / range.w
var elevation = orbitalPos.elevation / TWO_PI * 360.0
if (elevation > 90) elevation = 180 - elevation
orbitalPos.aboveHorizon = elevation - 0 > EPSILON
}
// Returns the ECI position and velocity of the observer
private fun calculateUserPosVel(
time: Double,
gsPos: GeoPos,
obsPos: Vector4,
obsVel: Vector4
) {
val mFactor = 7.292115E-5
gsPosTheta = mod2PI(thetaGJD(time) + DEG2RAD * gsPos.longitude)
val c = invert(sqrt(1.0 + FLAT_FACT * (FLAT_FACT - 2) * sqr(sin(DEG2RAD * gsPos.latitude))))
val sq = sqr(1.0 - FLAT_FACT) * c
val achcp = (EARTH_RADIUS * c + gsPos.altitude / 1000.0) * cos(DEG2RAD * gsPos.latitude)
obsPos.setXYZ(
achcp * cos(gsPosTheta), achcp * sin(gsPosTheta),
(EARTH_RADIUS * sq + gsPos.altitude / 1000.0) * sin(DEG2RAD * gsPos.latitude)
)
obsVel.setXYZ(-mFactor * obsPos.y, mFactor * obsPos.x, 0.0)
magnitude(obsPos)
magnitude(obsVel)
}
// Calculate the geodetic position of an object given its ECI pos and time
private fun calculateLatLonAlt(time: Double) {
orbitalPos.theta = atan2(position.y, position.x)
orbitalPos.longitude = mod2PI(orbitalPos.theta - thetaGJD(time))
val r = sqrt(sqr(position.x) + sqr(position.y))
val e2 = FLAT_FACT * (2.0 - FLAT_FACT)
orbitalPos.latitude = atan2(position.z, r)
var phi: Double
var c: Double
var i = 0
var converged: Boolean
do {
phi = orbitalPos.latitude
c = invert(sqrt(1.0 - e2 * sqr(sin(phi))))
orbitalPos.latitude = atan2(position.z + EARTH_RADIUS * c * e2 * sin(phi), r)
converged = abs(orbitalPos.latitude - phi) < EPSILON
} while (i++ < 10 && !converged)
orbitalPos.altitude = r / cos(orbitalPos.latitude) - EARTH_RADIUS * c
var temp = orbitalPos.latitude
if (temp > PI_2) {
temp -= TWO_PI
orbitalPos.latitude = temp
}
}
internal fun calculatePosAndVel(
rk: Double, uk: Double, xnodek: Double,
xinck: Double, rdotk: Double, rfdotk: Double
) {
// Orientation vectors
val sinuk = sin(uk)
val cosuk = cos(uk)
val sinik = sin(xinck)
val cosik = cos(xinck)
val sinnok = sin(xnodek)
val cosnok = cos(xnodek)
val xmx = -sinnok * cosik
val xmy = cosnok * cosik
val ux = xmx * sinuk + cosnok * cosuk
val uy = xmy * sinuk + sinnok * cosuk
val uz = sinik * sinuk
val vx = xmx * cosuk - cosnok * sinuk
val vy = xmy * cosuk - sinnok * sinuk
val vz = sinik * cosuk
// Position and velocity
position.setXYZ(ux, uy, uz)
position.multiply(rk)
velocity.x = rdotk * ux + rfdotk * vx
velocity.y = rdotk * uy + rfdotk * vy
velocity.z = rdotk * uz + rfdotk * vz
}
internal class Vector4(
var w: Double = 0.0,
var x: Double = 0.0,
var y: Double = 0.0,
var z: Double = 0.0
) {
fun multiply(multiplier: Double) {
x *= multiplier
y *= multiplier
z *= multiplier
}
fun setXYZ(xValue: Double, yValue: Double, zValue: Double) {
x = xValue
y = yValue
z = zValue
}
}
internal fun sqr(arg: Double): Double {
return arg * arg
}
internal fun invert(value: Double): Double {
return 1.0 / value
}
// Calculates the modulus of 2 * PI
internal fun mod2PI(value: Double): Double {
var retVal = value
val i = (retVal / TWO_PI).toInt()
retVal -= i * TWO_PI
if (retVal < 0.0) retVal += TWO_PI
return retVal
}
// Solves Keplers' Equation
internal fun converge(temp: DoubleArray, axn: Double, ayn: Double, capu: Double) {
var converged = false
var i = 0
do {
temp[7] = sin(temp[2])
temp[8] = cos(temp[2])
temp[3] = axn * temp[7]
temp[4] = ayn * temp[8]
temp[5] = axn * temp[8]
temp[6] = ayn * temp[7]
val epw = (capu - temp[4] + temp[3] - temp[2]) / (1.0 - temp[5] - temp[6]) + temp[2]
if (abs(epw - temp[2]) <= EPSILON) converged = true else temp[2] = epw
} while (i++ < 10 && !converged)
}
internal fun calculatePhase(xlt: Double, xnode: Double, omgadf: Double) {
var phaseValue = xlt - xnode - omgadf + TWO_PI
if (phaseValue < 0.0) phaseValue += TWO_PI
orbitalPos.phase = mod2PI(phaseValue)
}
// Sets perigee and checks and adjusts the calculation if the perigee is less tan 156KM
internal fun setPerigee(perigee: Double) {
this.perigee = perigee
checkPerigee()
}
// Checks and adjusts the calculation if the perigee is less tan 156KM
private fun checkPerigee() {
s4 = 1.012229
qoms24 = 1.880279E-09
if (perigee < 156.0) {
s4 = if (perigee <= 98.0) 20.0 else perigee - 78.0
qoms24 = ((120 - s4) / EARTH_RADIUS).pow(4.0)
s4 = s4 / EARTH_RADIUS + 1.0
}
}
// Checks if the satellite is in sunlight
private fun isEclipsed(): Boolean {
val sunVector = calculateSunVector()
val sdEarth = asin(EARTH_RADIUS / position.w)
val rho = subtract(sunVector, position)
val sdSun = asin(SOLAR_RADIUS / rho.w)
val earth = scalarNegMultiply(position)
val delta = angle(sunVector, earth)
eclipseDepth = sdEarth - sdSun - delta
return if (sdEarth < sdSun) false else eclipseDepth >= 0
}
private fun calculateSunVector(): Vector4 {
val mjd = julUTC - 2415020.0
val year = 1900 + mjd / 365.25
val solTime = (mjd + deltaEt(year) / SEC_PER_DAY) / 36525.0
val mTemp = modulus(35999.04975 * solTime, 360.0)
val m = radians(
modulus(358.47583 + mTemp - (0.000150 + 0.0000033 * solTime) * sqr(solTime), 360.0)
)
val lTemp = modulus(36000.76892 * solTime, 360.0)
val l = radians(
modulus(279.69668 + lTemp + 0.0003025 * sqr(solTime), 360.0)
)
val e = 0.01675104 - (0.0000418 + 0.000000126 * solTime) * solTime
val c = radians(
((1.919460 - (0.004789 + 0.000014 * solTime) * solTime) * sin(m))
+ ((0.020094 - 0.000100 * solTime) * sin(2 * m)) + 0.000293 * sin(3 * m)
)
val o = radians(modulus(259.18 - 1934.142 * solTime, 360.0))
val lsa = modulus(l + c - radians(0.00569 - 0.00479 * sin(o)), TWO_PI)
val nu = modulus(m + c, TWO_PI)
var r = (1.0000002 * (1.0 - sqr(e)) / (1.0 + e * cos(nu)))
val eps = radians(
23.452294 - (0.0130125 + (0.00000164 - 0.000000503 * solTime) * solTime)
* solTime + 0.00256 * cos(o)
)
r *= ASTRONOMICAL_UNIT
return Vector4(r, r * cos(lsa), r * sin(lsa) * cos(eps), r * sin(lsa) * sin(eps))
}
private fun subtract(v1: Vector4, v2: Vector4): Vector4 {
val v3 = Vector4()
v3.x = v1.x - v2.x
v3.y = v1.y - v2.y
v3.z = v1.z - v2.z
magnitude(v3)
return v3
}
private fun scalarNegMultiply(vector: Vector4): Vector4 {
val neg = -1.0
return Vector4(vector.w * abs(neg), vector.x * neg, vector.y * neg, vector.z * neg)
}
private fun angle(v1: Vector4, v2: Vector4): Double {
magnitude(v1)
magnitude(v2)
return acos(dot(v1, v2) / (v1.w * v2.w))
}
/**
* The function Delta_ET has been added to allow calculations on the
* position of the sun. It provides the difference between UT (approximately
* the same as UTC) and ET (now referred to as TDT) This function is based
* on a least squares fit of data from 1950 to 1991 and will need to be
* updated periodically.
*
* Values determined using data from 1950-1991 in the 1990 Astronomical
* Almanac. See DELTA_ET.WQ1 for details.
*/
private fun deltaEt(year: Double): Double {
return 26.465 + 0.747622 * (year - 1950) + (1.886913 * sin(TWO_PI * (year - 1975) / 33))
}
private fun radians(degrees: Double): Double {
return degrees * DEG2RAD
}
// Calculates the dot product of two vectors
private fun dot(v1: Vector4, v2: Vector4): Double {
return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z
}
// Returns fractional part of double argument
private fun fraction(arg: Double): Double {
return arg - floor(arg)
}
// Calculates scalar magnitude of a vector4 argument
private fun magnitude(v: Vector4) {
v.w = sqrt(sqr(v.x) + sqr(v.y) + sqr(v.z))
}
private fun modulus(arg1: Double, arg2: Double = SEC_PER_DAY): Double {
var returnValue = arg1
val i = floor(returnValue / arg2).toInt()
returnValue -= i * arg2
if (returnValue < 0.0) returnValue += arg2
return returnValue
}
// Multiplies the vector v1 by the scalar k
private fun scaleVector(k: Double, v: Vector4) {
v.multiply(k)
magnitude(v)
}
private fun thetaGJD(theJD: Double): Double {
val earthRotPerSidDay = 1.00273790934
val ut = fraction(theJD + 0.5)
val aJD = theJD - ut
val tu = (aJD - 2451545.0) / 36525.0
var gmst = 24110.54841 + tu * (8640184.812866 + tu * (0.093104 - tu * 6.2E-6))
gmst = modulus(gmst + SEC_PER_DAY * earthRotPerSidDay * ut)
return TWO_PI * gmst / SEC_PER_DAY
}
}
@@ -0,0 +1,33 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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
data class OrbitalPass(
val aosTime: Long = 0L,
val aosAzimuth: Double = 90.0,
val losTime: Long = 0L,
val losAzimuth: Double = 270.0,
val altitude: Int = 1000,
val maxElevation: Double = 75.0,
val orbitalObject: OrbitalObject,
var progress: Float = 0.0f
) {
val catNum: Int = orbitalObject.data.catnum
val name: String = orbitalObject.data.name
val isDeepSpace: Boolean = orbitalObject.data.isDeepSpace
}
@@ -0,0 +1,72 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.predict
import kotlin.math.acos
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
data class OrbitalPos(
var azimuth: Double = 0.0,
var elevation: Double = 0.0,
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var altitude: Double = 0.0,
var distance: Double = 0.0,
var distanceRate: Double = 0.0,
var theta: Double = 0.0,
var time: Long = 0L,
var phase: Double = 0.0,
var eclipseDepth: Double = 0.0,
var eclipsed: Boolean = false,
var aboveHorizon: Boolean = false
) {
fun getDownlinkFreq(freq: Long): Long {
return (freq.toDouble() * (SPEED_OF_LIGHT - distanceRate * 1000.0) / SPEED_OF_LIGHT).toLong()
}
fun getUplinkFreq(freq: Long): Long {
return (freq.toDouble() * (SPEED_OF_LIGHT + distanceRate * 1000.0) / SPEED_OF_LIGHT).toLong()
}
fun getOrbitalVelocity(): Double {
val earthG = 6.674 * 10.0.pow(-11)
val earthM = 5.98 * 10.0.pow(24)
val radius = 6.37 * 10.0.pow(6) + altitude * 10.0.pow(3)
return sqrt(earthG * earthM / radius) / 1000
}
fun getRangeCircle(): List<GeoPos> {
val rangeCirclePoints = mutableListOf<GeoPos>()
val beta = acos(EARTH_RADIUS / (EARTH_RADIUS + altitude)) // * EARTH_RADIUS = radiusKm
for (azimuth in 0..720) {
val rads = azimuth * DEG2RAD
val lat = asin(sin(latitude) * cos(beta) + (cos(latitude) * sin(beta) * cos(rads)))
val lon = (longitude + atan2(
sin(rads) * sin(beta) * cos(latitude), cos(beta) - sin(latitude) * sin(lat)
))
rangeCirclePoints.add(GeoPos(lat * RAD2DEG, lon * RAD2DEG))
}
return rangeCirclePoints
}
}
@@ -0,0 +1,25 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
interface IDatabaseRepo {
suspend fun updateTLEFromFile(uri: String)
suspend fun updateTransceiversFromFile(uri: String)
suspend fun updateFromRemote()
suspend fun clearAllData()
}
@@ -0,0 +1,22 @@
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
import kotlinx.coroutines.CoroutineScope
interface IMainContainer {
val appScope: CoroutineScope
val settingsRepo: ISettingsRepo
val selectionRepo: ISelectionRepo
val satelliteRepo: ISatelliteRepo
val databaseRepo: IDatabaseRepo
fun provideAddToCalendar(): IAddToCalendar
fun provideShowToast(): IShowToast
fun provideBluetoothReporter(): IReporterRepo<WithoutExtParams>
fun provideNetworkReporter(): IReporterRepo<ExtendedParams>
fun provideSensorsRepo(): ISensorsRepo
}
interface IContainerProvider {
fun getMainContainer(): IMainContainer
}
@@ -0,0 +1,34 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
interface IReporterParams
interface IReporterRepo<T : IReporterParams> {
fun isConnected(service: BtService): Boolean
fun isConnecting(service: BtService): Boolean
fun connect(service: BtService, deviceId: String)
fun reportRotation(format: String, azimuth: Double, elevation: Double, params: T)
fun reportFrequency(format: String, frequency: Long, params: T)
}
data class ExtendedParams(val server: String, val port: Int) : IReporterParams
data class WithoutExtParams(val dummy: Int) : IReporterParams
enum class BtService { ROTATOR, FREQUENCY }
@@ -0,0 +1,37 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import kotlinx.coroutines.flow.StateFlow
interface ISatelliteRepo {
val passes: StateFlow<List<OrbitalPass>>
val satellites: StateFlow<List<OrbitalObject>>
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun initRepository()
suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio>
suspend fun processPasses(passList: List<OrbitalPass>, time: Long): List<OrbitalPass>
suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List<String>)
}
@@ -0,0 +1,32 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.SatItem
import kotlinx.coroutines.flow.Flow
interface ISelectionRepo {
fun getCurrentTypes(): List<String>
fun getTypesList(): List<String>
suspend fun getEntriesFlow(): Flow<List<SatItem>>
suspend fun setTypes(types: List<String>)
suspend fun setQuery(query: String)
suspend fun setSelection(selectAll: Boolean)
suspend fun setSelection(ids: List<Int>, isTicked: Boolean)
suspend fun saveSelection()
}
@@ -0,0 +1,28 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
interface ISensorsRepo {
val orientation: StateFlow<Pair<Float, Float>>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
fun enableSensor()
fun disableSensor()
}
@@ -0,0 +1,95 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
interface ISettingsRepo {
val appVersionName: String
//region # Satellites selection settings
val selectedIds: StateFlow<List<Int>>
val selectedTypes: StateFlow<List<String>>
fun setSelectedIds(ids: List<Int>)
fun setSelectedTypes(types: List<String>)
//endregion
//region # Passes filter settings
val passesSettings: StateFlow<PassesSettings>
fun setPassesSettings(settings: PassesSettings)
//endregion
//region # Station position settings
val stationPosition: StateFlow<GeoPos>
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
fun setStationPosition(): Boolean
fun setStationPosition(locator: String): Boolean
//endregion
//region # Database update settings
val databaseState: StateFlow<DatabaseState>
fun getSatelliteTypesIds(types: List<String>): List<Int>
fun setSatelliteTypeIds(type: String, ids: List<Int>)
fun updateDatabaseState(state: DatabaseState)
//endregion
//region # RC settings
val rcSettings: StateFlow<RCSettings>
fun setBluetoothRotatorAddress(value: String)
fun setBluetoothRotatorFormat(value: String)
fun setBluetoothRotatorName(value: String)
fun setBluetoothRotatorState(value: Boolean)
fun setBluetoothFrequencyAddress(value: String)
fun setBluetoothFrequencyFormat(value: String)
fun setBluetoothFrequencyState(value: Boolean)
fun setRotatorAddress(value: String)
fun setRotatorPort(value: String)
fun setRotatorState(value: Boolean)
fun setRotatorFormat(value: String)
fun setFrequencyAddress(value: String)
fun setFrequencyPort(value: String)
fun setFrequencyState(value: Boolean)
fun setFrequencyFormat(value: String)
//endregion
//region # Other settings
val otherSettings: StateFlow<OtherSettings>
fun setStateOfAutoUpdate(value: Boolean)
fun setStateOfSensors(value: Boolean)
fun setStateOfSweep(value: Boolean)
fun setStateOfUtc(value: Boolean)
fun setStateOfLightTheme(value: Boolean)
fun setWarningDismissed()
fun setWhatsNewDismissed()
//endregion
//region # Transceivers settings
val dataSourcesSettings: StateFlow<DataSourcesSettings>
fun setUseCustomTle(value: Boolean)
fun setUseCustomTransceivers(value: Boolean)
fun setTleUrl(value: String)
fun setTransceiversUrl(value: String)
//endregion
}
@@ -0,0 +1,36 @@
/*
* 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.source
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
interface ILocalSource {
suspend fun getEntriesTotal(): Int
suspend fun getEntriesList(): List<SatItem>
suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject>
suspend fun insertEntries(entries: List<OrbitalData>)
suspend fun deleteEntries()
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun deleteRadios()
}
@@ -0,0 +1,25 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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.source
import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
}
@@ -0,0 +1,52 @@
/*
* 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.source
object Sources {
const val RADIO_DATA_URL = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
val satelliteDataUrls = mapOf(
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
"Education" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=education&FORMAT=csv",
"Engineer" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=engineering&FORMAT=csv",
"Geostationary" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=geo&FORMAT=csv",
"Globalstar" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=globalstar&FORMAT=csv",
"GNSS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=gnss&FORMAT=csv",
"Intelsat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=intelsat&FORMAT=csv",
"Iridium" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=iridium-NEXT&FORMAT=csv",
"Military" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=military&FORMAT=csv",
"New" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=last-30-days&FORMAT=csv",
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
"Swarm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=swarm&FORMAT=csv",
"Weather" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=csv",
"X-Comm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=x-comm&FORMAT=csv",
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
"R4UAB" to "https://r4uab.ru/satonline.txt",
"Other" to "" // key for sats filter
)
}
@@ -0,0 +1,22 @@
/*
* 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.usecase
interface IAddToCalendar {
operator fun invoke(name: String, aosTime: Long, losTime: Long)
}
@@ -0,0 +1,22 @@
/*
* 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.usecase
interface IShowToast {
operator fun invoke(message: String)
}
@@ -0,0 +1,154 @@
/*
* 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.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.InputStream
import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) {
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val parsedItems = mutableListOf<OrbitalData>()
stream.bufferedReader().useLines { lines ->
lines.forEachIndexed { index, line ->
if (index != 0) {
val values = line.split(",")
parseCSV(values)?.let { tle -> parsedItems.add(tle) }
}
}
}
return@withContext parsedItems
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
val tleStrings = mutableListOf(String(), String(), String())
val parsedItems = mutableListOf<OrbitalData>()
var lineIndex = 0
stream.bufferedReader().forEachLine { line ->
tleStrings[lineIndex] = line
if (lineIndex < 2) {
lineIndex++
} else {
val isLineOneValid = tleStrings[1].substring(0, 1) == "1"
val isLineTwoValid = tleStrings[2].substring(0, 1) == "2"
if (!isLineOneValid && !isLineTwoValid) return@forEachLine
parseTLE(tleStrings)?.let { tle -> parsedItems.add(tle) }
lineIndex = 0
}
}
return@withContext parsedItems
}
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
val parsedItems = mutableListOf<SatRadio>()
try {
val jsonArray = JSONArray(stream.bufferedReader().readText())
for (index in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(index)
parseJSON(jsonObject)?.let { parsedItems.add(it) }
}
return@withContext parsedItems
} catch (_: Exception) {
return@withContext parsedItems
}
}
fun isLeapYear(year: Int): Boolean {
return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)
}
private fun parseCSV(values: List<String>): OrbitalData? = try {
val name = values[0]
val year = values[2].substring(0, 4)
val month = values[2].substring(5, 7)
val dayOfMonth = values[2].substring(8, 10)
val dayInt = getDayOfYear(year.toInt(), month.toInt(), dayOfMonth.toInt())
val day = if (dayInt < 10) "00$dayInt" else if (dayInt < 100) "0$dayInt" else "$dayInt"
val hour = values[2].substring(11, 13).toInt() * 3600000 // ms in one hour
val min = values[2].substring(14, 16).toInt() * 60000 // ms in one minute
val sec = values[2].substring(17, 19).toInt() * 1000 // ms in one second
val ms = values[2].substring(20, 26).toInt() / 1000.0 // microseconds to ms
val frac = ((hour + min + sec + ms) / 86400000.0).toString()
val epoch = "${year.substring(2)}$day${frac.substring(1)}".toDouble()
val meanmo = values[3].toDouble()
val eccn = values[4].toDouble()
val incl = values[5].toDouble()
val raan = values[6].toDouble()
val argper = values[7].toDouble()
val meanan = values[8].toDouble()
val catnum = values[11].toInt()
val bstar = values[14].toDouble()
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("CSV parsing exception: $exception")
null
}
private fun parseTLE(tle: List<String>): OrbitalData? = try {
val name: String = tle[0].trim()
val epoch: Double = tle[1].substring(18, 32).toDouble()
val meanmo: Double = tle[2].substring(52, 63).toDouble()
val eccn: Double = tle[2].substring(26, 33).toDouble() / 10000000.0
val incl: Double = tle[2].substring(8, 16).toDouble()
val raan: Double = tle[2].substring(17, 25).toDouble()
val argper: Double = tle[2].substring(34, 42).toDouble()
val meanan: Double = tle[2].substring(43, 51).toDouble()
val catnum: Int = tle[1].substring(2, 7).trim().toInt()
val bstar: Double = 1.0e-5 * tle[1].substring(53, 59).toDouble() / 10.0.pow(tle[1].substring(60, 61).toDouble())
OrbitalData(name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar)
} catch (exception: Exception) {
println("TLE parsing exception: $exception")
null
}
private fun parseJSON(json: JSONObject): SatRadio? = try {
val uuid = json.getString("uuid")
val info = json.getString("description")
val alive = json.getBoolean("alive")
val dlinkLow = if (json.isNull("downlink_low")) null else json.getLong("downlink_low")
val dlinkHigh = if (json.isNull("downlink_high")) null else json.getLong("downlink_high")
val dlinkMode = if (json.isNull("mode")) null else json.getString("mode")
val ulinkLow = if (json.isNull("uplink_low")) null else json.getLong("uplink_low")
val ulinkHigh = if (json.isNull("uplink_high")) null else json.getLong("uplink_high")
val ulinkMode = if (json.isNull("uplink_mode")) null else json.getString("uplink_mode")
val inverted = json.getBoolean("invert")
val catnum = if (json.isNull("norad_cat_id")) null else json.getInt("norad_cat_id")
SatRadio(uuid, info, alive, dlinkLow, dlinkHigh, dlinkMode, ulinkLow, ulinkHigh, ulinkMode, inverted, catnum)
} catch (exception: Exception) {
println("JSON parsing exception: $exception")
null
}
private fun getDayOfYear(year: Int, month: Int, dayOfMonth: Int): Int {
if (month == 1) return dayOfMonth
val daysArray = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
var dayOfYear = dayOfMonth
// If leap year increment Feb days
if (isLeapYear(year)) daysArray[1]++
for (i in 0 until month - 1) {
dayOfYear += daysArray[i]
}
return dayOfYear
}
}
@@ -0,0 +1,69 @@
/*
* 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.utility
import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(this)
val minutes = TimeUnit.MILLISECONDS.toMinutes(this) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(this) % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
}
fun Float.round(decimals: Int): Float {
var multiplier = 1.0f
repeat(decimals) { multiplier *= 10 }
return kotlin.math.round(this * multiplier) / multiplier
}
fun Double.round(decimals: Int): Double {
var multiplier = 1.0
repeat(decimals) { multiplier *= 10 }
return kotlin.math.round(this * multiplier) / multiplier
}
//fun String.getHash(type: String = "SHA-256"): String {
// val hexChars = "0123456789ABCDEF"
// val bytes = MessageDigest.getInstance(type).digest(this.toByteArray())
// val result = StringBuilder(bytes.size * 2)
// bytes.forEach {
// val i = it.toInt()
// result.append(hexChars[i shr 4 and 0x0f])
// result.append(hexChars[i and 0x0f])
// }
// return result.toString()
//}
//fun String.isValidEmail(): Boolean {
// val expression = "^[\\w.-]+@([\\w\\-]+\\.)+[A-Z]{2,8}$"
// val pattern = Pattern.compile(expression, Pattern.CASE_INSENSITIVE)
// return pattern.matcher(this).matches()
//}
//fun String.isValidIPv4(): Boolean {
// val ip4 = "^((\\d|[1-9]\\d|1\\d\\d|2[0-4]\\d|25[0-5])(\\.(?!\$)|\$)){4}\$"
// return this.matches(ip4.toRegex())
//}
//fun String.isValidPort(): Boolean {
// val port = "([1-9]|[1-9]\\d{1,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])"
// return this.matches(port.toRegex()) && this.toInt() in 1024..65535
//}
@@ -0,0 +1,64 @@
/*
* 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.utility
import com.rtbishop.look4sat.core.domain.predict.DEG2RAD
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
import kotlin.math.max
import kotlin.math.min
private const val MIN_LATITUDE = -85.05112877980658
private const val MAX_LATITUDE = 85.05112877980658
private const val MIN_LONGITUDE = -180.0
private const val MAX_LONGITUDE = 180.0
fun Double.toDegrees(): Double = this * RAD2DEG
fun Double.toRadians(): Double = this * DEG2RAD
//fun Double.latToY01(): Double {
// val sinus = sin(clipLat(this) * PI / MAX_LONGITUDE)
// return 0.5 - ln((1 + sinus) / (1 - sinus)) / (4 * PI)
//}
//fun Double.lonToX01(): Double {
// return (clipLon(this) - MIN_LONGITUDE) / (MAX_LONGITUDE - MIN_LONGITUDE)
//}
//fun Double.y01ToLat(): Double {
// return 90 - 360 * atan(exp((this - 0.5) * 2 * PI)) / PI
//}
//fun Double.x01ToLon(): Double {
// return MIN_LONGITUDE + (MAX_LONGITUDE - MIN_LONGITUDE) * this
//}
fun clipLat(latitude: Double): Double {
return clip(latitude, MIN_LATITUDE, MAX_LATITUDE)
}
fun clipLon(longitude: Double): Double {
var result = longitude
while (result < MIN_LONGITUDE) result += 360.0
while (result > MAX_LONGITUDE) result -= 360.0
return clip(result, MIN_LONGITUDE, MAX_LONGITUDE)
}
private fun clip(currentValue: Double, minValue: Double, maxValue: Double): Double {
return min(max(currentValue, minValue), maxValue)
}
@@ -0,0 +1,55 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos
fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6)
if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90
val longitude = (lonFirst + lonSecond + lonThird).round(4)
val latitude = (latFirst + latSecond + latThird).round(4)
return GeoPos(latitude, longitude)
}
fun positionToQth(latitude: Double, longitude: Double): String? {
if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt()
val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
}
private fun isValidPosition(lat: Double, lon: Double): Boolean {
return (lat >= -90.0 && lat <= 90.0) && (lon >= -180.0 && lon <= 360.0)
}
private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex())
}
@@ -0,0 +1,105 @@
/*
* 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
import com.rtbishop.look4sat.core.domain.utility.DataParser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ExperimentalCoroutinesApi
class DataParserTest {
private val testDispatcher = StandardTestDispatcher()
private val dataParser = DataParser(testDispatcher)
private val validCSVStream = """
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,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0
""".trimIndent().byteInputStream()
private val invalidCSVStream = """
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
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
""".trimIndent().byteInputStream()
private val validTLEStream = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
ISS (ZARYA)
1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999
2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058
""".trimIndent().byteInputStream()
private val invalidTLEStream = """
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private val validJSONStream = """
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
private val invalidJSONStream = """
[{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
@Test
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
}
@Test
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
}
@Test
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseTLEStream(validTLEStream)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
}
@Test
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
}
@Test
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
}
@Test
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
}
@Test
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
}
@Test
fun `Function isLeapYear returns correct data`() = runTest(testDispatcher) {
val years = listOf(1900, 1984, 1994, 2016, 2022, 2024, 2042, 2048)
val answers = listOf(false, true, false, true, false, true, false, true)
val results = years.map { dataParser.isLeapYear(it) }
assert(results == answers)
}
}
@@ -0,0 +1,51 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import org.junit.Test
class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
assert(positionToQth(51.4878, -0.2146) == "IO91vl")
assert(positionToQth(48.1466, 11.6083) == "JN58td")
}
@Test
fun `Given invalid POS returns null`() {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.convention.composeLibraryPlugin)
}
android {
namespace = "com.rtbishop.look4sat.core.presentation"
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
@@ -0,0 +1,414 @@
/*
* 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.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.rtbishop.look4sat.core.domain.predict.NearEarthObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sdfTime = SimpleDateFormat("HH:mm:ss", Locale.ENGLISH)
@Composable
@Preview(showBackground = true)
private fun TopBarPreview() = MainTheme {
TopBar {
IconCard(action = {}, resId = R.drawable.ic_filter)
TimerRow(timeString = "88:88:88", isTimeAos = true)
IconCard(action = {}, resId = R.drawable.ic_radios)
}
}
@Composable
fun TopBar(content: @Composable (RowScope.() -> Unit)) {
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
}
@Composable
fun RowScope.TimerRow(timeString: String, isTimeAos: Boolean) {
val colorScheme = MaterialTheme.colorScheme
val aosColor = if (isTimeAos) colorScheme.primary else colorScheme.onSurface
val losColor = if (!isTimeAos) colorScheme.primary else colorScheme.onSurface
ElevatedCard(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxSize()
) {
Text(text = "AOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = aosColor)
Text(
text = timeString,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(text = "LOS", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = losColor)
}
}
}
@Composable
@Preview(showBackground = true)
private fun NextPassRowPreview() = MainTheme {
TopBar { NextPassRow(pass = getDefaultPass()) }
}
@Composable
fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier) {
ElevatedCard(
modifier = modifier
.height(48.dp)
.weight(1f)
) {
Column(
verticalArrangement = Arrangement.spacedBy((-2).dp),
modifier = Modifier
.fillMaxSize()
// .background(color = MaterialTheme.colorScheme.background)
.padding(start = 6.dp, top = 1.dp, end = 6.dp, bottom = 0.dp)
) {
val passSatId = stringResource(id = R.string.pass_satId, pass.catNum)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$passSatId - ",
color = MaterialTheme.colorScheme.primary
)
Text(
text = pass.name,
modifier = Modifier
.weight(1f)
.padding(end = 6.dp)
.infiniteMarquee(),
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_elevation),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${pass.maxElevation}°",
color = MaterialTheme.colorScheme.primary
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = sdfTime.format(Date(pass.aosTime)),
fontSize = 15.sp,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = 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
)
}
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(
id = R.string.pass_aosLos,
pass.aosAzimuth.toInt(),
pass.losAzimuth.toInt()
),
fontSize = 15.sp
)
}
}
}
}
}
@Composable
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
ElevatedButton(
onClick = { onClick() }, colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
), shape = MaterialTheme.shapes.small, modifier = modifier,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
) { Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center) }
}
@Composable
fun IconCard(action: () -> Unit, resId: Int, modifier: Modifier = Modifier) {
val clickableMod = Modifier.clickable { action() }
ElevatedCard(modifier = Modifier.size(48.dp)) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(resId), contentDescription = null, modifier = modifier)
}
}
}
@Composable
fun PrimaryIconCard(modifier: Modifier = Modifier, resId: Int, onClick: () -> Unit) {
val clickableMod = modifier.clickable { onClick() }
ElevatedCard(
modifier = Modifier.size(102.dp, 48.dp),
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Box(modifier = clickableMod.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painter = painterResource(id = resId), contentDescription = null)
}
}
}
@Composable
fun CardLoadingIndicator() {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.size(80.dp))
}
}
@Composable
fun EmptyListCard(message: String) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Text(text = """¯\_(ツ)_/¯""", fontSize = 32.sp)
Text(text = stringResource(R.string.empty_list_message), fontSize = 21.sp, textAlign = TextAlign.Center)
Text(text = message, fontSize = 18.sp, textAlign = TextAlign.Center)
}
}
}
fun getDefaultPass(): OrbitalPass {
val orbitalData = OrbitalData(
""" ¯\_(ツ)_/¯ ⚠️""",
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0,
0.0
)
val satellite = NearEarthObject(orbitalData)
return OrbitalPass(0L, 0.0, Long.MAX_VALUE, 0.0, 0, 0.0, satellite, 0f)
}
@Composable
fun SharedDialog(
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = { onCancel() }) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
Text(
text = title,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
content()
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
CardButton(onClick = onCancel, text = stringResource(id = R.string.btn_cancel))
Spacer(modifier = Modifier.weight(1f))
CardButton(onClick = onAccept, text = stringResource(id = R.string.btn_accept))
}
}
}
}
}
@Composable
fun InfoDialog(title: String, text: String, onDismiss: () -> Unit) {
val padding = LocalSpacing.current.large
Dialog(onDismissRequest = {}) {
ElevatedCard {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(padding)
) {
Text(
text = title,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = padding, top = padding, end = padding)
)
Text(
text = text,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = padding)
)
Row(modifier = Modifier.padding(start = padding, bottom = padding, end = padding)) {
Spacer(modifier = Modifier.weight(1f))
CardButton(
onClick = onDismiss,
text = stringResource(id = R.string.btn_accept)
)
}
}
}
}
}
@Composable
fun hasEnoughHeight(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isHeightAtLeastBreakpoint(480)
}
@Composable
fun hasEnoughWidth(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600)
}
@Composable
fun isVerticalLayout(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(600).not()
}
@Composable
fun Modifier.infiniteMarquee(): Modifier {
return basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
}
@Composable
fun Modifier.layoutPadding(): Modifier {
val statusBarMod = this.statusBarsPadding()
val spacing = LocalSpacing.current.extraSmall
return statusBarMod.padding(start = spacing, top = 0.dp, end = spacing, bottom = spacing)
}
@Composable
fun ScreenColumn(
topBar: @Composable (Boolean) -> Unit = {},
floatingBar: @Composable () -> Unit = {},
content: @Composable (Boolean) -> Unit = {}
) {
val isVerticalLayout = isVerticalLayout()
Surface(color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.layoutPadding(), contentAlignment = Alignment.BottomCenter) {
Column(verticalArrangement = Arrangement.spacedBy(LocalSpacing.current.extraSmall)) {
topBar(isVerticalLayout)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceContainer
) { content(isVerticalLayout) }
}
floatingBar()
}
}
}
@Composable
fun TopBar(
isVerticalLayout: Boolean,
startAction: @Composable () -> Unit,
topInfo: @Composable (RowScope.() -> Unit),
bottomInfo: @Composable (RowScope.() -> Unit),
endAction: @Composable () -> Unit
) {
val topBarRow = @Composable { content: @Composable RowScope.() -> Unit ->
Row(
modifier = Modifier.height(48.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) { content() }
}
if (isVerticalLayout) {
topBarRow { startAction().also { topInfo() }.also { endAction() } }
topBarRow { bottomInfo() }
} else {
topBarRow { startAction().also { topInfo() }.also { bottomInfo() }.also { endAction() } }
}
}
@@ -0,0 +1,161 @@
/*
* Look4Sat. Amateur radio satellite tracker and pass predictor.
* Copyright (C) 2019-2026 Arty Bishop and contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.presentation
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
val LocalSpacing = compositionLocalOf { Spacing }
data object Spacing {
val extraExtraSmall = 4.dp
val extraSmall = 6.dp
val small = 8.dp
val medium = 12.dp
val large = 16.dp
val extraLarge = 24.dp
}
@Composable
fun MainTheme(isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val view = LocalView.current
if (view.isInEditMode) {
MaterialTheme(darkScheme, shapes, typography, content)
} else {
CompositionLocalProvider(LocalSpacing provides Spacing) {
val colorScheme = if (isDarkTheme) darkScheme else lightScheme
SideEffect {
val window = (view.context as ComponentActivity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
}
MaterialTheme(colorScheme, shapes, typography, content)
}
}
}
private val lightScheme = lightColorScheme(
primary = Color(0xFF715C0C),
onPrimary = Color(0xFFFFFFFF),
secondary = Color(0xFF685E40),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFF1E1BB),
onSecondaryContainer = Color(0xFF221B04),
background = Color(0xFFFFF8F0),
onBackground = Color(0xFF1E1B13),
surface = Color(0xFFFFF8F0),
onSurface = Color(0xFF1E1B13),
surfaceTint = Color(0x00000000),
surfaceVariant = Color(0xFFEBE2CF),
onSurfaceVariant = Color(0xFF4C4639),
error = Color(0xFFDC0000),
outline = Color(0xFF7D7667),
scrim = Color(0xFF000000)
)
private val darkScheme = darkColorScheme(
primary = Color(0xFFFFE082), // main accent, switch background, active progress
onPrimary = Color(0xFF000000), // on main accent, switch knob
// primaryContainer = Color(0xFF121212),
// onPrimaryContainer = Color(0xFF121212),
// inversePrimary = Color(0xFF121212),
secondary = Color(0xFFE0E0E0),
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFF404040), // navBar indicator,
onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon
// tertiary = Color(0xFF121212),
// onTertiary = Color(0xFF121212),
// tertiaryContainer = Color(0xFF121212),
// onTertiaryContainer = Color(0xFF121212),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF202020), // card background
onSurface = Color(0xFFE0E0E0), // on card background
surfaceVariant = Color(0xFF404040), // buttons background
onSurfaceVariant = Color(0xFFE0E0E0), // navBar inactive icon
surfaceTint = Color(0x00000000),
// inverseSurface = Color(0xFF121212),
// inverseOnSurface = Color(0xFF121212),
error = Color(0xFFDC0000),
// onError = Color(0xFFFFFFFF),
// errorContainer = Color(0xFF121212),
// onErrorContainer = Color(0xFF121212),
outline = Color(0xA3E0E0E0),
// outlineVariant = Color(0xFF121212),
scrim = Color(0xFF000000),
// surfaceBright = Color(0xFF121212),
surfaceContainer = Color(0xFF121212), // navBar background
// surfaceContainerHigh = Color(0xFF121212),
surfaceContainerHighest = Color(0xFF242424), // filled card background
surfaceContainerLow = Color(0xFF202020), // elevated card background
surfaceContainerLowest = Color(0xCC121212),
// surfaceDim = Color(0xFF121212),
)
private val shapes = Shapes(
extraSmall = RoundedCornerShape(4.dp), // menus, snackbars, text fields
small = RoundedCornerShape(8.dp), // buttons, chips
medium = RoundedCornerShape(12.dp), // cards, small FABs
large = RoundedCornerShape(24.dp), // extended FABs, FABs, nav drawers
extraLarge = RoundedCornerShape(32.dp) // large FABs
)
private val typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
), titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
), labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
@@ -0,0 +1,9 @@
package com.rtbishop.look4sat.core.presentation
sealed class Screen(val route: String, val iconResId: Int, val titleResId: Int) {
data object Satellites : Screen("satellites", R.drawable.ic_satellites, R.string.nav_sat)
data object Passes : Screen("passes", R.drawable.ic_passes, R.string.nav_pass)
data object Radar : Screen("radar", R.drawable.ic_radar, R.string.nav_radar)
data object Map : Screen("map", R.drawable.ic_map, R.string.nav_map)
data object Settings : Screen("settings", R.drawable.ic_settings, R.string.nav_prefs)
}
@@ -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="M4,20h16v2L4,22zM4,2h16v2L4,4zM13,9h3l-4,-4 -4,4h3v6L8,15l4,4 4,-4h-3z" />
</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="M504,480L320,296L376,240L616,480L376,720L320,664L504,480Z" />
</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="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</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="M200,320L760,320L760,240Q760,240 760,240Q760,240 760,240L200,240Q200,240 200,240Q200,240 200,240L200,320ZM200,320L200,240Q200,240 200,240Q200,240 200,240L200,240Q200,240 200,240Q200,240 200,240L200,320ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240Q120,207 143.5,183.5Q167,160 200,160L240,160L240,80L320,80L320,160L640,160L640,80L720,80L720,160L760,160Q793,160 816.5,183.5Q840,207 840,240L840,467Q821,458 801,452Q781,446 760,443L760,400L200,400L200,800Q200,800 200,800Q200,800 200,800L452,800Q459,822 468.5,842Q478,862 491,880L200,880ZM720,920Q637,920 578.5,861.5Q520,803 520,720Q520,637 578.5,578.5Q637,520 720,520Q803,520 861.5,578.5Q920,637 920,720Q920,803 861.5,861.5Q803,920 720,920ZM787,815L815,787L740,712L740,600L700,600L700,728L787,815Z" />
</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="M19,5v14H5V5h14m0,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
</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="M19,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.11,0 2,-0.9 2,-2L21,5c0,-1.1 -0.89,-2 -2,-2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</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="M612,668L668,612L520,464L520,280L440,280L440,496L612,668ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,800Q613,800 706.5,706.5Q800,613 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,613 253.5,706.5Q347,800 480,800Z" />
</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="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</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="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="@android:color/white"
android:pathData="M495.3,425.74H255.18c-3.58,-52 -23.78,-100.69 -58.12,-140.07L401.37,81.37c6.52,-6.52 6.52,-17.09 0,-23.61c-6.52,-6.52 -17.09,-6.52 -23.61,0L162.23,273.29L4.89,430.63c-4.77,4.77 -6.21,11.96 -3.62,18.2s8.67,10.3 15.42,10.3h222.61h256c9.22,0 16.7,-7.47 16.7,-16.7C512,433.21 504.52,425.74 495.3,425.74z" />
</vector>
@@ -0,0 +1,10 @@
<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:fillType="evenOdd"
android:pathData="M1.854,1.146a0.5,0.5 0,1 0,-0.708 0.708l2,2A1.494,1.494 0,0 0,3 4.5L3,8a1.5,1.5 0,0 0,1.5 1.5h15A1.5,1.5 0,0 0,21 8L21,4.5c0,-0.232 -0.052,-0.45 -0.146,-0.647l2,-2a0.5,0.5 0,0 0,-0.708 -0.707l-2,2A1.494,1.494 0,0 0,19.5 3h-15c-0.232,0 -0.45,0.052 -0.647,0.146l-2,-2ZM7.5,8C8.328,8 9,7.216 9,6.25S8.328,4.5 7.5,4.5 6,5.284 6,6.25 6.672,8 7.5,8ZM18,6.25C18,7.216 17.328,8 16.5,8S15,7.216 15,6.25s0.672,-1.75 1.5,-1.75 1.5,0.784 1.5,1.75ZM4.5,10.5A1.5,1.5 0,0 0,3 12v9.5A1.5,1.5 0,0 0,4.5 23h15a1.5,1.5 0,0 0,1.5 -1.5L21,12a1.5,1.5 0,0 0,-1.5 -1.5h-15ZM8,16.75a4,4 0,1 1,8 0,4 4,0 0,1 -8,0ZM12,11.75a5,5 0,1 0,0 10,5 5,0 0,0 0,-10ZM12,18.5a1.75,1.75 0,1 0,-1.436 -2.75L9.437,15.75a2.751,2.751 0,1 1,0 2h1.127c0.316,0.453 0.841,0.75 1.436,0.75Z" />
</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="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z" />
</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="M12,0c-6.626,0 -12,5.373 -12,12 0,5.302 3.438,9.8 8.207,11.387 0.599,0.111 0.793,-0.261 0.793,-0.577v-2.234c-3.338,0.726 -4.033,-1.416 -4.033,-1.416 -0.546,-1.387 -1.333,-1.756 -1.333,-1.756 -1.089,-0.745 0.083,-0.729 0.083,-0.729 1.205,0.084 1.839,1.237 1.839,1.237 1.07,1.834 2.807,1.304 3.492,0.997 0.107,-0.775 0.418,-1.305 0.762,-1.604 -2.665,-0.305 -5.467,-1.334 -5.467,-5.931 0,-1.311 0.469,-2.381 1.236,-3.221 -0.124,-0.303 -0.535,-1.524 0.117,-3.176 0,0 1.008,-0.322 3.301,1.23 0.957,-0.266 1.983,-0.399 3.003,-0.404 1.02,0.005 2.047,0.138 3.006,0.404 2.291,-1.552 3.297,-1.23 3.297,-1.23 0.653,1.653 0.242,2.874 0.118,3.176 0.77,0.84 1.235,1.911 1.235,3.221 0,4.609 -2.807,5.624 -5.479,5.921 0.43,0.372 0.823,1.102 0.823,2.222v3.293c0,0.319 0.192,0.694 0.801,0.576 4.765,-1.589 8.199,-6.086 8.199,-11.386 0,-6.627 -5.373,-12 -12,-12z" />
</vector>
@@ -0,0 +1,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:endColor="#2A2A2A"
android:startColor="#000000" />
</shape>
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="271.24"
android:viewportHeight="271.24">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="10.8496"
android:translateY="10.8496">
<group
android:translateX="1.3562195"
android:translateY="1.3562195">
<group
android:translateX="70.26573"
android:translateY="70.26573">
<path
android:fillColor="@color/accent"
android:pathData="m96.64,31.5c7.91,8 17.14,13.58 25.36,15.58l1.75,-4C123.78,43.05 123.79,43.01 123.8,42.97 125.83,38.99 127,34.49 127,29.72 127,13.88 114.38,1 98.87,1c-5.13,0 -9.92,1.43 -14.06,3.88l-3.4,1.6c0.08,0.31 0.13,0.6 0.22,0.91 2.32,8.1 7.65,16.67 15.02,24.11z" />
<path
android:fillColor="@color/accent"
android:pathData="M93.88,34.3C86.05,26.39 80.37,17.23 77.87,8.49 77.84,8.39 77.82,8.28 77.79,8.18L2.63,43.51c-1.4,0.66 -2.02,2.36 -1.37,3.79 0.47,1.05 1.49,1.66 2.55,1.66 0.39,0 0.79,-0.08 1.17,-0.26L74.04,16.24c-1.87,3.58 -3,7.61 -3.23,11.89L35.57,51.73c-1.29,0.87 -1.65,2.64 -0.81,3.96 0.54,0.84 1.43,1.29 2.34,1.29 0.53,0 1.06,-0.15 1.53,-0.47L71.19,34.71c1.4,8.11 6.14,15.04 12.72,19.28l-54.94,68.34c-0.98,1.22 -0.81,3.02 0.39,4.02 0.52,0.44 1.15,0.65 1.77,0.65 0.81,0 1.61,-0.36 2.17,-1.04L89.06,56.6c3.06,1.17 6.36,1.84 9.81,1.84 4.93,0 9.57,-1.31 13.6,-3.6L84.6,118.32c-0.63,1.44 0,3.13 1.41,3.78 0.37,0.17 0.76,0.25 1.14,0.25 1.07,0 2.09,-0.63 2.56,-1.69L120.4,50.74C111.64,48.42 102.04,42.56 93.88,34.3Z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="271.24"
android:viewportHeight="271.24">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="10.8496"
android:translateY="10.8496">
<group
android:translateX="1.3562195"
android:translateY="1.3562195">
<group
android:translateX="70.26573"
android:translateY="70.26573">
<path
android:fillColor="@color/accent"
android:pathData="m96.64,31.5c7.91,8 17.14,13.58 25.36,15.58l1.75,-4C123.78,43.05 123.79,43.01 123.8,42.97 125.83,38.99 127,34.49 127,29.72 127,13.88 114.38,1 98.87,1c-5.13,0 -9.92,1.43 -14.06,3.88l-3.4,1.6c0.08,0.31 0.13,0.6 0.22,0.91 2.32,8.1 7.65,16.67 15.02,24.11z" />
<path
android:fillColor="@color/accent"
android:pathData="M93.88,34.3C86.05,26.39 80.37,17.23 77.87,8.49 77.84,8.39 77.82,8.28 77.79,8.18L2.63,43.51c-1.4,0.66 -2.02,2.36 -1.37,3.79 0.47,1.05 1.49,1.66 2.55,1.66 0.39,0 0.79,-0.08 1.17,-0.26L74.04,16.24c-1.87,3.58 -3,7.61 -3.23,11.89L35.57,51.73c-1.29,0.87 -1.65,2.64 -0.81,3.96 0.54,0.84 1.43,1.29 2.34,1.29 0.53,0 1.06,-0.15 1.53,-0.47L71.19,34.71c1.4,8.11 6.14,15.04 12.72,19.28l-54.94,68.34c-0.98,1.22 -0.81,3.02 0.39,4.02 0.52,0.44 1.15,0.65 1.77,0.65 0.81,0 1.61,-0.36 2.17,-1.04L89.06,56.6c3.06,1.17 6.36,1.84 9.81,1.84 4.93,0 9.57,-1.31 13.6,-3.6L84.6,118.32c-0.63,1.44 0,3.13 1.41,3.78 0.37,0.17 0.76,0.25 1.14,0.25 1.07,0 2.09,-0.63 2.56,-1.69L120.4,50.74C111.64,48.42 102.04,42.56 93.88,34.3Z" />
</group>
</group>
</group>
</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="M480,520Q430,520 395,485Q360,450 360,400Q360,350 395,315Q430,280 480,280Q530,280 565,315Q600,350 600,400Q600,450 565,485Q530,520 480,520ZM240,920L240,611Q202,569 181,515Q160,461 160,400Q160,266 253,173Q346,80 480,80Q614,80 707,173Q800,266 800,400Q800,461 779,515Q758,569 720,611L720,920L480,840L240,920ZM480,640Q580,640 650,570Q720,500 720,400Q720,300 650,230Q580,160 480,160Q380,160 310,230Q240,300 240,400Q240,500 310,570Q380,640 480,640ZM320,801L480,760L640,801L640,677Q605,697 564.5,708.5Q524,720 480,720Q436,720 395.5,708.5Q355,697 320,677L320,801ZM480,739L480,739Q480,739 480,739Q480,739 480,739Q480,739 480,739Q480,739 480,739L480,739L480,739Z" />
</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="M600,840L360,756L174,828Q154,836 137,823.5Q120,811 120,790L120,230Q120,217 127.5,207Q135,197 148,192L360,120L600,204L786,132Q806,124 823,136.5Q840,149 840,170L840,730Q840,743 832.5,753Q825,763 812,768L600,840ZM560,742L560,274L400,218L400,686L560,742ZM640,742L760,702L760,228L640,274L640,742ZM200,732L320,686L320,218L200,258L200,732ZM640,274L640,274L640,742L640,742L640,274ZM320,218L320,218L320,686L320,686L320,218Z" />
</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="M80,800L80,640L240,640L240,800L80,800ZM320,800L320,640L880,640L880,800L320,800ZM80,560L80,400L240,400L240,560L80,560ZM320,560L320,400L880,400L880,560L320,560ZM80,320L80,160L240,160L240,320L80,320ZM320,320L320,160L880,160L880,320L320,320Z" />
</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="M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,529 771,607.5Q742,686 688,746L560,618Q542,629 521.5,634.5Q501,640 480,640Q414,640 367,593Q320,546 320,480Q320,414 367,367Q414,320 480,320Q546,320 593,367Q640,414 640,480Q640,502 634.5,522.5Q629,543 618,562L678,622Q698,581 709,536Q720,491 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796Q506,788 529.5,775.5Q553,763 576,746L632,802Q599,829 560.5,849Q522,869 480,880ZM480,560Q513,560 536.5,536.5Q560,513 560,480Q560,447 536.5,423.5Q513,400 480,400Q447,400 423.5,423.5Q400,447 400,480Q400,513 423.5,536.5Q447,560 480,560ZM488,483L488,483Q488,483 488,483Q488,483 488,483L488,483Q488,483 488,483Q488,483 488,483L488,483L488,483L488,483L488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Q488,483 488,483Z" />
</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="@color/accent"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</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="M14,21c1.93,0 3.62,-1.17 4,-3l-1.75,-0.88C16,18.21 15.33,19 14,19l-4.9,0c0.83,-1 1.5,-2.34 1.5,-4c0,-0.35 -0.03,-0.69 -0.08,-1L14,14v-2l-4.18,0C9,10.42 8,9.6 8,8c0,-1.93 1.57,-3.5 3.5,-3.5c1.5,0 2.79,0.95 3.28,2.28L16.63,6c-0.8,-2.05 -2.79,-3.5 -5.13,-3.5C8.46,2.5 6,4.96 6,8c0,1.78 0.79,2.9 1.49,4L6,12v2l2.47,0c0.08,0.31 0.13,0.64 0.13,1c0,2.7 -2.6,4 -2.6,4v2H14z" />
</vector>
@@ -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="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80L520,80L520,411Q538,422 549,439.5Q560,457 560,480Q560,513 536.5,536.5Q513,560 480,560Q447,560 423.5,536.5Q400,513 400,480Q400,457 411,439Q422,421 440,411L440,325Q388,339 354,381.5Q320,424 320,480Q320,546 367,593Q414,640 480,640Q546,640 593,593Q640,546 640,480Q640,444 625.5,413.5Q611,383 586,360L643,303Q678,336 699,381.5Q720,427 720,480Q720,580 650,650Q580,720 480,720Q380,720 310,650Q240,580 240,480Q240,390 297,323.5Q354,257 440,243L440,162Q321,177 240.5,267Q160,357 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,411 773,351Q746,291 699,247L756,190Q813,245 846.5,319.5Q880,394 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z" />
</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="M198,682Q141,625 110.5,552.5Q80,480 80,400Q80,320 110.5,247.5Q141,175 198,118L246,166Q199,213 173.5,273.5Q148,334 148,400Q148,466 173.5,526.5Q199,587 246,634L198,682ZM290,590Q252,552 232,503Q212,454 212,400Q212,346 232,297Q252,248 290,210L338,258Q309,287 294.5,323.5Q280,360 280,400Q280,440 294.5,476.5Q309,513 338,542L290,590ZM440,840L440,492Q413,480 396.5,455Q380,430 380,400Q380,358 409,329Q438,300 480,300Q522,300 551,329Q580,358 580,400Q580,430 563.5,455Q547,480 520,492L520,840L440,840ZM670,590L622,542Q651,513 665.5,476.5Q680,440 680,400Q680,360 665.5,323.5Q651,287 622,258L670,210Q708,248 728,297Q748,346 748,400Q748,454 728,503Q708,552 670,590ZM762,682L714,634Q761,587 786.5,526.5Q812,466 812,400Q812,334 786.5,273.5Q761,213 714,166L762,118Q819,175 849.5,247.5Q880,320 880,400Q880,480 849.5,552.5Q819,625 762,682Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="128"
android:viewportHeight="128">
<path
android:fillColor="@android:color/white"
android:pathData="m96.64,31.5c7.91,8 17.14,13.58 25.37,15.58l1.75,-4c0.02,-0.04 0.02,-0.08 0.04,-0.12 2.03,-3.97 3.2,-8.47 3.2,-13.25 0,-15.83 -12.62,-28.71 -28.13,-28.71 -5.13,0 -9.92,1.43 -14.06,3.88l-3.4,1.6c0.08,0.31 0.13,0.6 0.22,0.91 2.32,8.1 7.65,16.67 15.02,24.11z" />
<path
android:fillColor="@android:color/white"
android:pathData="m93.88,34.3c-7.82,-7.91 -13.51,-17.07 -16.01,-25.81 -0.03,-0.1 -0.05,-0.2 -0.08,-0.31l-75.16,35.33c-1.4,0.66 -2.02,2.36 -1.37,3.79 0.47,1.05 1.49,1.66 2.55,1.66 0.39,0 0.79,-0.08 1.17,-0.26l69.07,-32.47c-1.87,3.58 -3,7.61 -3.23,11.88l-35.25,23.61c-1.29,0.87 -1.65,2.64 -0.8,3.96 0.54,0.84 1.43,1.29 2.34,1.29 0.53,0 1.06,-0.15 1.53,-0.47l32.55,-21.8c1.4,8.11 6.13,15.04 12.71,19.28l-54.94,68.33c-0.98,1.22 -0.81,3.02 0.39,4.02 0.52,0.44 1.15,0.65 1.78,0.65 0.81,0 1.61,-0.36 2.17,-1.04l55.77,-69.36c3.06,1.17 6.36,1.84 9.81,1.84 4.93,0 9.57,-1.31 13.6,-3.6l-27.87,63.48c-0.63,1.44 0,3.13 1.41,3.78 0.37,0.17 0.76,0.25 1.14,0.25 1.07,0 2.09,-0.63 2.56,-1.69l30.69,-69.92c-8.76,-2.32 -18.36,-8.18 -26.53,-16.44z" />
</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="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</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="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z" />
</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,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
<monochrome android:drawable="@drawable/ic_launcher_mc" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_fg" />
<monochrome android:drawable="@drawable/ic_launcher_mc" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="btn_accept">Aceptar</string>
<string name="btn_cancel">Cancelar</string>
<string name="empty_list_message">No hay datos para mostrar</string>
<!-- Navigation -->
<string name="nav_sat">Satélites</string>
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_map">Mapa</string>
<string name="nav_prefs">Ajustes</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Tipo: %s</string>
<string name="sat_type_title">Seleccionar tipo de satélite</string>
<string name="sat_search_hint">Id - Nombre</string>
<string name="sat_search_clear">Vaciar</string>
<string name="sat_clear_all">Vaciar todo</string>
<string name="sat_select_all">Seleccionar todo</string>
<string name="sat_empty_list_message">
Asegúrese de que su consulta de búsqueda sea correcta y que la base de datos esté actualizada</string>
<string name="sat_warning_title">Alarma\!</string>
<string name="sat_warning_message">
Esta aplicación incluye más de 9000 satélites.
No tiene sentido rastrearlos todos a la vez.
\n\nIntenta siempre limitar la lista a los que te
interesen mediante la búsqueda y el selector de tipos.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filtrar pases</string>
<string name="pass_filter_elev">Elevación mínima</string>
<string name="pass_filter_hours">Horas por delante</string>
<string name="pass_modes_title">Tipo de modulación</string>
<string name="pass_elevation">Elevación: %.1f°</string>
<string name="pass_altitude">Altitud: %.0f km</string>
<string name="pass_empty_list_message">
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
<string name="pass_welcome_message">
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
<!-- Radar screen -->
<string name="radar_back">Atrás</string>
<string name="radar_notify">Notificar</string>
<string name="radar_az_text">Ácimut</string>
<string name="radar_el_text">Elevación</string>
<string name="radar_alt_text">Altitud</string>
<string name="radar_dist_text">Distancia</string>
<string name="radar_eclipsed">Eclipsado</string>
<string name="radar_downlink">Enlace descendente</string>
<string name="radar_uplink">Enlace ascendente</string>
<string name="radar_no_data">Este satélite no lleva transmisores</string>
<string name="radar_add">Añadir</string>
<string name="radar_data">Transmisores satélite:</string>
<string name="radar_inverted">Invertido: %s</string>
<string name="radar_mode">Modo: %s</string>
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Si</string>
<string name="radar_visible">Visible</string>
<!-- Map screen -->
<string name="map_prev">Anterior</string>
<string name="map_next">Siguiente</string>
<string name="map_azimuth">Ácimut: %.1f°</string>
<string name="map_elevation">Elevación: %.1f°</string>
<string name="map_altitude">Altitud: %.0f km</string>
<string name="map_distance">Distancia: %.0f km</string>
<string name="map_latitude">Latitud: %.1f°</string>
<string name="map_longitude">Longitud: %.1f°</string>
<string name="map_phase">Fase: %.1f°</string>
<string name="map_eclipsed">Eclipsado</string>
<string name="map_period">Período: %.0f min</string>
<string name="map_velocity">Velocidad: %.2f km/s</string>
<string name="map_visibility">Visibilidad: %s</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Soporte</string>
<string name="prefs_privacy_title">Política de privacidad</string>
<string name="prefs_updated_title">Actualizado: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Ubicación estación</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Revisa los permisos de ubicación</string>
<string name="prefs_loc_input_title">Ingresar</string>
<string name="prefs_loc_input_error">Ubicación no válida</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Ubicación QTH no válida</string>
<string name="prefs_loc_success">Ubicación actualizada con éxito</string>
<string name="prefs_station_title">Ajustar ubicaicón de la estación</string>
<string name="prefs_station_lat_text">Latitud de tu estación terrestre</string>
<string name="prefs_station_lon_text">Longitud de tu estación terrestre</string>
<string name="prefs_locator_title">Ajustes ubicación QTH</string>
<string name="prefs_locator_text">Ajustar posición de la estación con tu ubicación</string>
<string name="prefs_data_title">Datos satelitales</string>
<string name="prefs_data_entries">Satélites: %s</string>
<string name="prefs_data_radios">Transmisores: %s</string>
<string name="prefs_data_update">Actualizar</string>
<string name="prefs_data_import">Importar</string>
<string name="prefs_data_clear">Vaciar</string>
<string name="prefs_data_clear_success">Datos eliminados con éxito</string>
<string name="prefs_data_update_success">Actualización completada con éxito</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_net_title">Salida de datos de red</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">Dirección IP</string>
<string name="prefs_net_rotator_port_hint">Puerto</string>
<string name="prefs_net_rotator_format_hint">Formato de datos</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">Dirección IP</string>
<string name="prefs_net_frequency_port_hint">Puerto</string>
<string name="prefs_net_frequency_format_hint">Formato de datos</string>
<string name="prefs_bt_title">Salida por Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id dispositivo</string>
<string name="prefs_bt_rotator_output_hint">Formato de datos</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id dispositivo</string>
<string name="prefs_bt_frequency_output_hint">Formato de datos</string>
<string name="prefs_bt_perm_error">Revisa los permisos de Bluetooth</string>
<string name="prefs_other_title">Otros ajustes</string>
<string name="prefs_other_switch_utc">Mostrar tiempos de pase en UTC</string>
<string name="prefs_other_switch_update">Habilitar auto actualización de datos</string>
<string name="prefs_other_switch_sweep">Habilitar animaciones radar</string>
<string name="prefs_other_switch_sensors">Usar sensores para rotar vista de radar</string>
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
</resources>
@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="btn_accept">Принять</string>
<string name="btn_cancel">Отменить</string>
<string name="empty_list_message">Нет данных для отображения</string>
<!-- Navigation -->
<string name="nav_sat">Спутники</string>
<string name="nav_pass">Пролеты</string>
<string name="nav_radar">Радар</string>
<string name="nav_map">Карта</string>
<string name="nav_prefs">Настройки</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Выберите тип спутника</string>
<string name="sat_search_hint">Id - Название</string>
<string name="sat_search_clear">Очистить</string>
<string name="sat_clear_all">Очистить</string>
<string name="sat_select_all">Выбрать</string>
<string name="sat_empty_list_message">Убедитесь, что ваш поисковый запрос верен и база данных обновлена</string>
<string name="sat_warning_title">Внимание\!</string>
<string name="sat_warning_message">
В этом приложении перечислено более 9000 спутников.
Отслеживать их все одновременно бессмысленно.
\n\nВсегда старайтесь сузить список до тех,
которые вас интересуют, используя поиск и селектор типов.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фильтровать пролеты</string>
<string name="pass_filter_elev">Минимальная элевация</string>
<string name="pass_filter_hours">Количество часов</string>
<string name="pass_modes_title">Выберите тип модуляции</string>
<string name="pass_elevation">Элевация: %.1f°</string>
<string name="pass_altitude">Высота: %d км</string>
<string name="pass_empty_list_message">
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
<string name="pass_welcome_message">
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
<!-- Radar screen -->
<string name="radar_back">Назад</string>
<string name="radar_notify">Уведомить</string>
<string name="radar_az_text">Азим.</string>
<string name="radar_el_text">Элев.</string>
<string name="radar_alt_text">Выс.</string>
<string name="radar_dist_text">Дальн.</string>
<string name="radar_eclipsed">В тени</string>
<string name="radar_downlink">Нисходящий канал</string>
<string name="radar_uplink">Восходящий канал</string>
<string name="radar_no_data">У этого спутника нет трансиверов</string>
<string name="radar_add">Добавить</string>
<string name="radar_data">Трансиверы спутника:</string>
<string name="radar_inverted">Инверсия: %s</string>
<string name="radar_mode">Модуляция: %s</string>
<string name="radar_string_no">Нет</string>
<string name="radar_string_yes">Да</string>
<string name="radar_visible">Видимый</string>
<!-- Map screen -->
<string name="map_prev">Предыдущий</string>
<string name="map_next">Следующий</string>
<string name="map_azimuth">Азимут: %.1f°</string>
<string name="map_elevation">Элевация: %.1f°</string>
<string name="map_altitude">Высота: %.0fкм</string>
<string name="map_distance">Дистанция: %.0fкм</string>
<string name="map_latitude">Широта: %.1f°</string>
<string name="map_longitude">Долгота: %.1f°</string>
<string name="map_phase">Фаза: %.1f°</string>
<string name="map_eclipsed">В тени</string>
<string name="map_period">Период: %.0fмин</string>
<string name="map_velocity">Скорость: %.2fкм/с</string>
<string name="map_visibility">Видимость: %s</string>
<string name="map_visible">Видимый</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Поддержать</string>
<string name="prefs_privacy_title">Политика конфиденциальности</string>
<string name="prefs_updated_title">Обновлено: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Местоположение</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Нет разрешения использовать геоданные</string>
<string name="prefs_loc_input_title">Ввести</string>
<string name="prefs_loc_input_error">Введены неверные данные</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Неправильный локатор QTH</string>
<string name="prefs_loc_success">Позиция успешно обновлена</string>
<string name="prefs_station_title">Настройки местоположения</string>
<string name="prefs_station_lat_text">Введите широту наземной станции</string>
<string name="prefs_station_lon_text">Введите долготу наземной станции</string>
<string name="prefs_locator_title">Настройки локатора QTH</string>
<string name="prefs_locator_text">Введите позицию через локатор</string>
<string name="prefs_data_title">Данные спутников</string>
<string name="prefs_data_entries">Спутников: %s</string>
<string name="prefs_data_radios">Трансиверов: %s</string>
<string name="prefs_data_update">Обновить</string>
<string name="prefs_data_import">Импорт</string>
<string name="prefs_data_clear">Очистить</string>
<string name="prefs_data_clear_success">Очистка прошла успешно</string>
<string name="prefs_data_update_success">Обновление прошло успешно</string>
<string name="prefs_data_sources_title">Свои источники данных</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom Transceivers URL</string>
<string name="prefs_data_output_title">Вывод данных</string>
<string name="prefs_net_output">Сеть</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_net_title">Вывод сетевых данных</string>
<string name="prefs_net_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_net_rotator_ip_hint">IP адрес</string>
<string name="prefs_net_rotator_port_hint">Порт</string>
<string name="prefs_net_rotator_format_hint">Формат</string>
<string name="prefs_net_frequency_switch">Включить вывод частоты</string>
<string name="prefs_net_frequency_ip_hint">IP адрес</string>
<string name="prefs_net_frequency_port_hint">Порт</string>
<string name="prefs_net_frequency_format_hint">Формат</string>
<string name="prefs_bt_title">Вывод данных Bluetooth</string>
<string name="prefs_bt_rotator_switch">Включить вывод ротатора</string>
<string name="prefs_bt_rotator_device_hint">Id устройства</string>
<string name="prefs_bt_rotator_output_hint">Формат</string>
<string name="prefs_bt_frequency_switch">Включить вывод частоты</string>
<string name="prefs_bt_frequency_device_hint">Id устройства</string>
<string name="prefs_bt_frequency_output_hint">Формат</string>
<string name="prefs_bt_perm_error">Нет разрешения использовать bluetooth</string>
<string name="prefs_other_title">Другие настройки</string>
<string name="prefs_other_switch_utc">Показывать время по UTC</string>
<string name="prefs_other_switch_update">Обновлять данные автоматически</string>
<string name="prefs_other_switch_sweep">Показывать анимацию радара</string>
<string name="prefs_other_switch_sensors">Использовать сенсоры устройства</string>
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
</resources>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="btn_accept">පිළිගන්න</string>
<string name="btn_cancel">අවලංගු ක°</string>
<string name="empty_list_message">පෙන්වීමට දත්ත නැත</string>
<!-- Navigation -->
<string name="nav_sat">චන්ද්‍රිකා</string>
<string name="nav_pass">පසුකර යෑම්</string>
<string name="nav_radar">රේඩාර්</string>
<string name="nav_map">සිතියම</string>
<string name="nav_prefs">පසුතල</string>
<!-- Satellites screen -->
<string name="sat_type_hint">වර්ගය: %s</string>
<string name="sat_type_title">චන්ද්‍රිකා ආකාරය තෝරන්න</string>
<string name="sat_search_hint">Id - නම</string>
<string name="sat_search_clear">පිරිසිදු ක°</string>
<string name="sat_clear_all">සියල්ල පිරිසිදු ක°</string>
<string name="sat_select_all">සියල්ල තෝරන්න</string>
<string name="sat_empty_list_message">
ඔබගේ සෙවුම් විමසුම නිවැරදි බවත් දත්ත සමුදාය යාවත්කාලීන කර ඇති බවත් සහතික කර ගන්න</string>
<string name="sat_warning_title">අවවාදයයි\!</string>
<string name="sat_warning_message">
මෙම යෙදුමේ ලැයිස්තුගත කර ඇති චන්ද්‍රිකා 9000 කට වඩා තිබේ.
ඒවා සියල්ලම එකවර නිරීක්ෂණය කිරීම තේරුමක් නැති දෙයක්.
\n\nසෙවීම සහ වර්ග තේරීම හරහා ඔබ උනන්දුවක් දක්වන
ඒවාට පමණක් ලැයිස්තුව පටු කිරීමට සැමවිටම උත්සාහ කරන්න.</string>
<!-- Passes screen -->
<string name="pass_filter_title">පසුකිරිම් පිරිපහදුව</string>
<string name="pass_filter_elev">අවම උත්තෝලනය</string>
<string name="pass_filter_hours">ඉදිරි පැය</string>
<string name="pass_modes_title">මූර්ජන ආකාරය තෝරන්න</string>
<string name="pass_elevation">උත්තෝලනය: %.1f°</string>
<string name="pass_altitude">උන්නතාශය: %d km</string>
<string name="pass_empty_list_message">
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්‍රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
<string name="pass_error_message">ප්‍රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්‍රිකා පසුකර යෑම් නැත.</string>
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
<string name="pass_welcome_message">
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
<!-- Radar screen -->
<string name="radar_back">ආපසු</string>
<string name="radar_notify">දන්වනවා</string>
<string name="radar_az_text">දිගංශය</string>
<string name="radar_el_text">උත්තෝලනය</string>
<string name="radar_alt_text">උන්නතාශය</string>
<string name="radar_dist_text">පරතරය</string>
<string name="radar_eclipsed">අදෘශ්‍ය</string>
<string name="radar_downlink">පහළ සබැඳිය</string>
<string name="radar_uplink">උඩුගත කිරීම</string>
<string name="radar_no_data">මෙම චන්ද්‍රිකාව සම්ප්‍රෙෂණය නොකරයි</string>
<string name="radar_add">එකතු ක°</string>
<string name="radar_data">චන්ද්‍රිකා සම්ප්‍රේෂණයන්:</string>
<string name="radar_inverted">Inverted: %s</string>
<string name="radar_mode">ප්‍රාකාරය: %s</string>
<string name="radar_string_no">නෑ</string>
<string name="radar_string_yes">ඔව්</string>
<string name="radar_visible">දෘශ්‍යමාන</string>
<!-- Map screen -->
<string name="map_prev">පසුපස</string>
<string name="map_next">ඊළග</string>
<string name="map_azimuth">දිගංශය: %.1f°</string>
<string name="map_elevation">උත්තෝලනය: %.1f°</string>
<string name="map_altitude">උන්නතාශය: %.0f km</string>,
<string name="map_distance">පරතරය: %.0f km</string>
<string name="map_latitude">අක්‍ෂාංශ: %.1f°</string>
<string name="map_longitude">දේශාංශ: %.1f°</string>
<string name="map_phase">අංශය: %.1f°</string>
<string name="map_eclipsed">අදෘශ්‍ය</string>
<string name="map_period">කාලය: %.0f min</string>
<string name="map_velocity">ප්‍රවේශය: %.2f km/s</string>
<string name="map_visibility">දෘශ්‍යතාව: %s</string>
<string name="map_visible">දෘශ්‍ය</string>
<!-- Settings screen -->
<string name="prefs_donate_title">දායකත්ව</string>
<string name="prefs_privacy_title">රහස්යතා ප්රතිපත්තිය</string>
<string name="prefs_updated_title">යාවත්කාලීන කරන ලදී: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">පොළෙහි පිහිටීම</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">ඔබගේ location අවසර පරීක්ෂා ක°</string>
<string name="prefs_loc_input_title">ආදානය</string>
<string name="prefs_loc_input_error">ඇතුළත් කළ ස්ථානය වලංගු නොවේ</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">QTH locator වලංගු නොවේ</string>
<string name="prefs_loc_success">පිහිටීම යාවත්කාලීනය සාර්ථකයි</string>
<string name="prefs_station_title">පොළෙහි පිහිටීම් පසුතල</string>
<string name="prefs_station_lat_text">ඔබගේ පොළෙහි අක්‍ෂාංශය සකසන්න</string>
<string name="prefs_station_lon_text">ඔබගේ පොළෙහි දේශාංශය සකසන්න</string>
<string name="prefs_locator_title">QTH locator පසුතල</string>
<string name="prefs_locator_text"> ඔබගේ locator භාවිතා කර පොළහි පිහිටීම සකසන්න</string>
<string name="prefs_data_title">චන්ද්‍රිකා දත්ත</string>
<string name="prefs_data_entries">චන්ද්‍රිකා: %s</string>
<string name="prefs_data_radios">සම්ප්‍රෙේෂණය: %s</string>
<string name="prefs_data_update">නැවුම් කරන්න</string>
<string name="prefs_data_import">ආදානය</string>
<string name="prefs_data_clear">පිරිසිදු ක°</string>
<string name="prefs_data_clear_success">දත්ත ඉවත් කිරීම සාර්ථකයි</string>
<string name="prefs_data_update_success">යාවත්කාලීනය සම්පූර්ණ කිරීම සාර්ථකයි</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_net_title">ජාල ප්‍රතිදානය</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP ලිපිනය</string>
<string name="prefs_net_rotator_port_hint">Port</string>
<string name="prefs_net_rotator_format_hint">දත්ත අකාරය</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP ලිපිනය</string>
<string name="prefs_net_frequency_port_hint">Port</string>
<string name="prefs_net_frequency_format_hint">දත්ත අකාරය</string>
<string name="prefs_bt_title">Bluetooth දත්ත ප්‍රතිදානය</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">උපාංග id</string>
<string name="prefs_bt_rotator_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">උපාංග id</string>
<string name="prefs_bt_frequency_output_hint">දත්ත අකාරය</string>
<string name="prefs_bt_perm_error">Bluetooth අවසර පරික්‍ෂා ක°</string>
<string name="prefs_other_title">වෙනත් සැකසුම්</string>
<string name="prefs_other_switch_utc">පසුකර වේලාවන් UTC මගින්</string>
<string name="prefs_other_switch_update">ස්වයං දත්ත යාවත්කාලීනය</string>
<string name="prefs_other_switch_sweep">radar sweep සජීවිකරණය සබල කරන්න</string>
<string name="prefs_other_switch_sensors">Radar දර්ශනය කරකැවීමට සංවේදක භාවිතා කරන්න </string>
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
</resources>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="btn_accept">Прийняти</string>
<string name="btn_cancel">Скасувати</string>
<string name="empty_list_message">Немає даних для відображення</string>
<!-- Navigation -->
<string name="nav_sat">Супутники</string>
<string name="nav_pass">Прольоти</string>
<string name="nav_radar">Радар</string>
<string name="nav_map">Карта</string>
<string name="nav_prefs">Налаштування</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Тип: %s</string>
<string name="sat_type_title">Виберіть тип супутника</string>
<string name="sat_search_hint">Id - Назва</string>
<string name="sat_search_clear">Очистити</string>
<string name="sat_clear_all">Очистити всі</string>
<string name="sat_select_all">Вибрати всі</string>
<string name="sat_empty_list_message">
Переконайтеся, що ваш пошуковий запит правильний, а база даних оновлена</string>
<string name="sat_warning_title">Увага\!</string>
<string name="sat_warning_message">
У цьому додатку перелічено понад 9000 супутників.
Немає сенсу відстежувати їх усі одночасно.
\n\nЗавжди намагайтеся звузити список лише до тих,
які вас цікавлять, за допомогою пошуку та селектора типів.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Фільтр прольотів</string>
<string name="pass_filter_elev">Мінімальне піднесення</string>
<string name="pass_filter_hours">Кількість годин</string>
<string name="pass_modes_title">Виберіть тип модуляції</string>
<string name="pass_elevation">Піднес.: %.1f°</string>
<string name="pass_altitude">Висота: %.0f км</string>
<string name="pass_empty_list_message">
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
<string name="pass_welcome_message">
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
<!-- Radar screen -->
<string name="radar_back">Назад</string>
<string name="radar_notify">Сповістити</string>
<string name="radar_az_text">Азимут</string>
<string name="radar_el_text">Піднес.</string>
<string name="radar_alt_text">Висота</string>
<string name="radar_dist_text">Відстань</string>
<string name="radar_eclipsed">В тіні</string>
<string name="radar_downlink">Низхідний канал</string>
<string name="radar_uplink">Вихідний канал</string>
<string name="radar_no_data">Цей супутник не має трансиверів</string>
<string name="radar_add">Додати</string>
<string name="radar_data">Супутникові трансивери:</string>
<string name="radar_inverted">Інверсія: %s</string>
<string name="radar_mode">Модуляція: %s</string>
<string name="radar_string_no">Ні</string>
<string name="radar_string_yes">Так</string>
<string name="radar_visible">Видимий</string>
<!-- Map screen -->
<string name="map_prev">Попередній</string>
<string name="map_next">Наступний</string>
<string name="map_azimuth">Азимут: %.1f°</string>
<string name="map_elevation">Піднесення: %.1f°</string>
<string name="map_altitude">Висота: %.0f км</string>
<string name="map_distance">Дистанція: %.0f км</string>
<string name="map_latitude">Широта: %.1f°</string>
<string name="map_longitude">Довгота: %.1f°</string>
<string name="map_phase">Фаза: %.1f°</string>
<string name="map_eclipsed">В тіні</string>
<string name="map_period">Період: %.0f хв</string>
<string name="map_velocity">Швидкість: %.2f км/с</string>
<string name="map_visibility">Видимість: %s</string>
<string name="map_visible">Видимий</string>
<!-- Settings screen -->
<string name="prefs_donate_title">Підтримати</string>
<string name="prefs_privacy_title">Політика конфіденційності</string>
<string name="prefs_updated_title">Оновлено: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Місцезнаходження</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Перевірте дозвіл на використання геолокацї</string>
<string name="prefs_loc_input_title">Ввести</string>
<string name="prefs_loc_input_error">Введено некоректну локацію</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Некоректний локатор QTH</string>
<string name="prefs_loc_success">Позицію успішно оновлено</string>
<string name="prefs_station_title">Налаштування місцезнаходження станції</string>
<string name="prefs_station_lat_text">Широта місцезнаходження станції</string>
<string name="prefs_station_lon_text">Довгота місцезнаходження станції</string>
<string name="prefs_locator_title">Налаштування QTH локатора</string>
<string name="prefs_locator_text">Задати позицію за QTH локатором</string>
<string name="prefs_data_title">Супутникові дані</string>
<string name="prefs_data_entries">Супутників: %s</string>
<string name="prefs_data_radios">Трансиверів: %s</string>
<string name="prefs_data_update">Оновити</string>
<string name="prefs_data_import">Імпорт</string>
<string name="prefs_data_clear">Очистити</string>
<string name="prefs_data_clear_success">Дані успішно очищено</string>
<string name="prefs_data_update_success">Оновлення успішне</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_net_title">Вивід мережевих даних</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP адреса</string>
<string name="prefs_net_rotator_port_hint">Порт</string>
<string name="prefs_net_rotator_format_hint">Формат даних</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP адреса</string>
<string name="prefs_net_frequency_port_hint">Порт</string>
<string name="prefs_net_frequency_format_hint">Формат даних</string>
<string name="prefs_bt_title">Вивід даних Bluetooth</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Id пристрою</string>
<string name="prefs_bt_rotator_output_hint">Формат даних</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Id пристрою</string>
<string name="prefs_bt_frequency_output_hint">Формат даних</string>
<string name="prefs_bt_perm_error">Перевірте дозвіл Bluetooth</string>
<string name="prefs_other_title">Інші налаштування</string>
<string name="prefs_other_switch_utc">Показувати час в UTC</string>
<string name="prefs_other_switch_update">Автоматичне оновлення даних</string>
<string name="prefs_other_switch_sweep">Показувати анімацію радара</string>
<string name="prefs_other_switch_sensors">Використовувати сенсори пристрою</string>
<string name="prefs_outro_title">Я хотів би подякувати:</string>
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
</resources>
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="btn_accept">确定</string>
<string name="btn_cancel">取消</string>
<string name="empty_list_message">没有数据可显示</string>
<!-- Navigation -->
<string name="nav_sat">卫星</string>
<string name="nav_pass">过境</string>
<string name="nav_radar">雷达</string>
<string name="nav_map">地图</string>
<string name="nav_prefs">设置</string>
<!-- Satellites screen -->
<string name="sat_type_hint">类型:%s</string>
<string name="sat_type_title">选择卫星类型</string>
<string name="sat_search_hint">Id - 名称</string>
<string name="sat_search_clear">清空</string>
<string name="sat_clear_all">全部清空</string>
<string name="sat_select_all">全选</string>
<string name="sat_empty_list_message">请确保您的搜索查询正确且数据库已更新</string>
<string name="sat_warning_title">警告\!</string>
<string name="sat_warning_message">此应用收录了超过9000颗卫星信息\n同时追踪所有卫星并不现实\n建议始终通过搜索和分类筛选功能仅勾选您感兴趣的卫星</string>
<!-- Passes screen -->
<string name="pass_filter_title">筛选过境</string>
<string name="pass_filter_elev">仰角值</string>
<string name="pass_filter_hours">小时数</string>
<string name="pass_modes_title">选择调制类型</string>
<string name="pass_elevation">仰角:%.1f°</string>
<string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- Radar screen -->
<string name="radar_back">后退</string>
<string name="radar_notify">通知</string>
<string name="radar_az_text">方位角</string>
<string name="radar_el_text">仰角</string>
<string name="radar_alt_text">高度</string>
<string name="radar_dist_text">距离</string>
<string name="radar_eclipsed">地影中</string>
<string name="radar_downlink">下行链路</string>
<string name="radar_uplink">上行链路</string>
<string name="radar_no_data">这颗卫星没有收发器</string>
<string name="radar_add">添加</string>
<string name="radar_data">卫星收发器:</string>
<string name="radar_inverted">倒置:%s</string>
<string name="radar_mode">调制:%s</string>
<string name="radar_string_no">否</string>
<string name="radar_string_yes">是</string>
<string name="radar_visible">日照中</string>
<!-- Map screen -->
<string name="map_prev">上一个</string>
<string name="map_next">下一个</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
<string name="map_distance">距离:%.0f km</string>
<string name="map_latitude">纬度:%.1f°</string>
<string name="map_longitude">经度:%.1f°</string>
<string name="map_phase">相位:%.1f°</string>
<string name="map_eclipsed">地影中</string>
<string name="map_period">周期:% .0f min</string>
<string name="map_velocity">速度:%.2f km/s</string>
<string name="map_visibility">能见度:%s</string>
<string name="map_visible">日照中</string>
<!-- Settings screen -->
<string name="prefs_donate_title">支持</string>
<string name="prefs_privacy_title">隐私政策</string>
<string name="prefs_updated_title">更新:%s</string>
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
<string name="prefs_loc_title">站位</string>
<string name="prefs_loc_gps_title">GPS 定位</string>
<string name="prefs_loc_gps_error">检查您的位置权限</string>
<string name="prefs_loc_input_title">输入位置</string>
<string name="prefs_loc_input_error">输入的位置无效</string>
<string name="prefs_loc_qth_title">QTH 网格</string>
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
<string name="prefs_loc_success">位置更新成功</string>
<string name="prefs_station_title">站位设置</string>
<string name="prefs_station_lat_text">输入站位的纬度</string>
<string name="prefs_station_lon_text">输入站位的经度</string>
<string name="prefs_locator_title">QTH 网格</string>
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
<string name="prefs_data_title">卫星数据更新</string>
<string name="prefs_data_entries">卫星:%s</string>
<string name="prefs_data_radios">收发器:%s</string>
<string name="prefs_data_update">在线更新</string>
<string name="prefs_data_import">导入文件</string>
<string name="prefs_data_clear">清空</string>
<string name="prefs_data_clear_success">数据清空成功</string>
<string name="prefs_data_update_success">更新成功</string>
<string name="prefs_data_sources_title">自定义数据源</string>
<string name="prefs_data_sources_tle_switch">自定义TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">自定义收发器URL</string>
<string name="prefs_data_output_title">数据输出</string>
<string name="prefs_net_output">网络</string>
<string name="prefs_bt_output">蓝牙</string>
<string name="prefs_net_title">网络数据输出</string>
<string name="prefs_net_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_net_rotator_ip_hint">IP地址</string>
<string name="prefs_net_rotator_port_hint">端口</string>
<string name="prefs_net_rotator_format_hint">数据格式</string>
<string name="prefs_net_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_net_frequency_ip_hint">IP地址</string>
<string name="prefs_net_frequency_port_hint">端口</string>
<string name="prefs_net_frequency_format_hint">数据格式</string>
<string name="prefs_bt_title">蓝牙数据输出</string>
<string name="prefs_bt_rotator_switch">启用方位俯仰角输出</string>
<string name="prefs_bt_rotator_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_rotator_output_hint">数据格式</string>
<string name="prefs_bt_frequency_switch">启用多普勒频率输出</string>
<string name="prefs_bt_frequency_device_hint">蓝牙设备 ID</string>
<string name="prefs_bt_frequency_output_hint">数据格式</string>
<string name="prefs_bt_perm_error">请检查蓝牙权限</string>
<string name="prefs_other_title">其他设置</string>
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
<string name="prefs_other_switch_sensors">使用传感器旋转雷达视图</string>
<string name="prefs_outro_title">我要感谢:</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources>
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string>
<string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string>
<!-- Navigation -->
<string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string>
<string name="nav_map">Map</string>
<string name="nav_prefs">Settings</string>
<!-- Satellites screen -->
<string name="sat_type_hint">Type: %s</string>
<string name="sat_type_title">Select satellite type</string>
<string name="sat_search_hint">Id - Name</string>
<string name="sat_search_clear">Clear</string>
<string name="sat_clear_all">Clear all</string>
<string name="sat_select_all">Select all</string>
<string name="sat_empty_list_message">"Make sure your search query is correct and the DB is updated"</string>
<string name="sat_warning_title">Warning\!</string>
<string name="sat_warning_message">
There are over 9000 satellites listed in this app.
It makes no sense to track them all at the same time.
\n\nAlways try to narrow down the list to only the ones
you\'re interested in via search and types selector.</string>
<!-- Passes screen -->
<string name="pass_filter_title">Filter passes</string>
<string name="pass_filter_elev">Minimal elevation</string>
<string name="pass_filter_hours">Hours ahead</string>
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
<string name="pass_modes_title">Select modulation type</string>
<string name="pass_satId" translatable="false">%05d</string>
<string name="pass_elevation">Elevation: %.1f°</string>
<string name="pass_deep_space" translatable="false">DeepSpace</string>
<string name="pass_altitude">Altitude: %d km</string>
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
<string name="pass_empty_list_message">
Make sure your filters are correct and you have selected satellites</string>
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
<string name="pass_welcome_message">
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false">
• Londre7 added a remote frequency control feature (Hamlib/SDR++) by sending compatible messages via Network/BT.
Enable it in Settings and tap the transceiver (Radar Screen).
</string>
<!-- Radar screen -->
<string name="radar_back">Back</string>
<string name="radar_notify">Notify</string>
<string name="radar_az_text">Azimuth</string>
<string name="radar_az_value" translatable="false">%.1f°</string>
<string name="radar_el_text">Elevation</string>
<string name="radar_el_value" translatable="false">%.1f°</string>
<string name="radar_alt_text">Altitude</string>
<string name="radar_alt_value" translatable="false">%.0f km</string>
<string name="radar_dist_text">Distance</string>
<string name="radar_dist_value" translatable="false">%.0f km</string>
<string name="radar_eclipsed">Eclipsed</string>
<string name="radar_downlink">Downlink</string>
<string name="radar_uplink">Uplink</string>
<string name="radar_link_low" translatable="false">%.4f</string>
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
<string name="radar_no_link" translatable="false">- - . - -</string>
<string name="radar_no_data">This satellite has no transceivers</string>
<string name="radar_add">Add</string>
<string name="radar_data">Satellite transceivers:</string>
<string name="radar_inverted">Inverted: %s</string>
<string name="radar_mode">Mode: %s</string>
<string name="radar_string_no">No</string>
<string name="radar_string_yes">Yes</string>
<string name="radar_visible">Visible</string>
<!-- Map screen -->
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
<string name="map_distance">Distance: %.0f km</string>
<string name="map_latitude">Latitude: %.1f°</string>
<string name="map_longitude">Longitude: %.1f°</string>
<string name="map_qth" translatable="false">QTH: %s</string>
<string name="map_phase">Phase: %.1f°</string>
<string name="map_eclipsed">Eclipsed</string>
<string name="map_period">Period: %.0f min</string>
<string name="map_velocity">Velocity: %.2f km/s</string>
<string name="map_visibility">Visibility: %s</string>
<string name="map_visible">Visible</string>
<!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
<string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string>
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
<string name="prefs_privacy_title">Privacy Policy</string>
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
<string name="prefs_updated_title">Updated: %s</string>
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
<string name="prefs_loc_title">Station position</string>
<string name="prefs_loc_gps_title">GPS</string>
<string name="prefs_loc_gps_error">Check your location permissions</string>
<string name="prefs_loc_input_title">Input</string>
<string name="prefs_loc_input_error">Invalid location entered</string>
<string name="prefs_loc_qth_title">QTH</string>
<string name="prefs_loc_qth_error">Invalid QTH locator</string>
<string name="prefs_loc_success">Position updated successfully</string>
<string name="prefs_station_title">Station position settings</string>
<string name="prefs_station_lat_text">Set your ground station\'s latitude</string>
<string name="prefs_station_lon_text">Set your ground station\'s longitude</string>
<string name="prefs_locator_title">QTH locator settings</string>
<string name="prefs_locator_text">Set station\'s position using locator</string>
<string name="prefs_data_title">Satellite data</string>
<string name="prefs_data_entries">Satellites: %s</string>
<string name="prefs_data_radios">Transceivers: %s</string>
<string name="prefs_data_update">Update</string>
<string name="prefs_data_import">Import</string>
<string name="prefs_data_clear">Clear</string>
<string name="prefs_data_clear_success">Data was cleared successfully</string>
<string name="prefs_data_update_success">Update completed successfully</string>
<string name="prefs_data_sources_title">Custom data sources</string>
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
<string name="prefs_data_output_title">Data output</string>
<string name="prefs_net_output">Network</string>
<string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_net_title">Network data output</string>
<string name="prefs_net_rotator_switch">Enable rotation output</string>
<string name="prefs_net_rotator_ip_hint">IP address</string>
<string name="prefs_net_rotator_port_hint">Port</string>
<string name="prefs_net_rotator_format_hint">Format</string>
<string name="prefs_net_frequency_switch">Enable frequency output</string>
<string name="prefs_net_frequency_ip_hint">IP address</string>
<string name="prefs_net_frequency_port_hint">Port</string>
<string name="prefs_net_frequency_format_hint">Format</string>
<string name="prefs_bt_title">Bluetooth data output</string>
<string name="prefs_bt_rotator_switch">Enable rotation output</string>
<string name="prefs_bt_rotator_device_hint">Device id</string>
<string name="prefs_bt_rotator_output_hint">Data format</string>
<string name="prefs_bt_frequency_switch">Enable frequency output</string>
<string name="prefs_bt_frequency_device_hint">Device id</string>
<string name="prefs_bt_frequency_output_hint">Data format</string>
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
<string name="prefs_other_title">Other settings</string>
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
<string name="prefs_other_switch_update">Enable automatic data update</string>
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
<string name="prefs_other_switch_sensors">Use sensors to rotate radar view</string>
<string name="prefs_outro_title">I would like to say thanks to</string>
<string name="prefs_outro_thanks" translatable="false">
• Look4Sat users and contributors!
\n• David A. B. Johnson (predict4java)
\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">The app comes with no warranty</string>
</resources>
@@ -0,0 +1,9 @@
<resources>
<color name="accent">#FFE082</color>
<color name="background">#121212</color>
<style name="Theme.Look4Sat.SplashScreen" parent="Theme.SplashScreen">
<item name="postSplashScreenTheme">@android:style/Theme.Material.NoActionBar</item>
<item name="windowSplashScreenBackground">@color/background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_fg</item>
</style>
</resources>