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()
}
}