Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,26 @@ Installation sur un telephone connecte en USB (debogage USB active) :
adb install -r app/build/outputs/apk/debug/app-debug.apk
```

## Tests

Les calculs (visee geostationnaire, analyse du ciel GNSS, encodage NTP, priorite des sources de
temps, mesure directionnelle 360°, azimuts de l'interface) sont couverts par des tests unitaires
JVM, sans emulateur :

```bash
./gradlew test
./gradlew lint
```

Les essais terrain (plusieurs telephones, plusieurs regions, modems differents) restent a faire sur
appareils reels.

## Langues

Tous les textes affiches sont dans `app/src/main/res/values/strings.xml` (francais, langue par
defaut) et `app/src/main/res/values-en/strings.xml` (anglais). Ajouter une langue revient a copier
le fichier dans un dossier `values-xx`.

## Permissions

| Permission | Usage |
Expand Down
39 changes: 39 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# Notes de version

## v1.1.0 — 30 aout 2026

Version de fiabilite : aucune fonction retiree, mais des erreurs enfin expliquees a l'ecran, des
textes traduisibles, une consommation reduite et des calculs couverts par des tests.

APK : `dist/SatTemps-v1.1.0-debug.apk` (ou `./gradlew assembleDebug`).

### Ameliorations

**Messages d'erreur et etats**
- Localisation : service absent, permission refusee (avec acces direct aux reglages en cas de refus
definitif), GPS desactive, recherche de fix en cours, signal perdu avec l'age du dernier fix.
- Reseau mobile : telephonie indisponible, permission refusee, aucune cellule remontee par le modem,
echec de lecture.
- Heure : serveur NTP injoignable ou reponse invalide, indicateur de synchronisation en cours,
reessais espaces progressivement hors couverture.
- Boussole : capteur absent, echec d'enregistrement, besoin de calibrage.
- Mesure 360° : la couverture du tour est affichee et signalee comme insuffisante sous 70 %.

**Langues**
- Tous les textes sont dans `res/values/strings.xml` (francais) et `res/values-en/strings.xml`
(anglais) ; nombres, dates et heures suivent la langue du telephone.

**Batterie**
- Horloge a 40 ms ecran allume, 200 ms en economie d'energie, 1 s en arriere plan.
- Modem interroge chaque seconde pendant la mesure 360°, toutes les 5 s au repos ; calculs GNSS et
de visee ralentis hors premier plan.

**Qualite du code**
- 46 tests unitaires JVM : visee geostationnaire, analyse du ciel GNSS, encodage NTP, priorite des
sources de temps et espacement des reessais, mesure directionnelle 360°, conversions d'azimut.
- Logique de calcul separee des API Android (`SkyAnalysis`, `NtpTimestamp`, `Azimuth`) et KDoc sur
les classes principales.

### Limites inchangees

Les limites de la v1.0.0 restent valables. Les essais terrain sur plusieurs telephones, plusieurs
regions et differents modems restent a faire sur materiel reel.

## v1.0.0 — 30 aout 2026

Premiere version de **SatTemps** : heure exacte et meilleure orientation d'antenne
Expand Down
8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ android {
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}

testOptions {
unitTests.isReturnDefaultValues = true
}
}

dependencies {
Expand All @@ -56,4 +60,8 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")

testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.24")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}
48 changes: 41 additions & 7 deletions app/src/main/java/com/mikael/sattemps/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.mikael.sattemps

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
Expand All @@ -19,6 +22,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
Expand All @@ -31,6 +36,11 @@ private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.READ_PHONE_STATE
)

/**
* Ecran unique de l'application : demande les permissions necessaires puis affiche
* [MainScreen]. Tant que la localisation precise est refusee, seule l'explication
* est visible car aucune mesure n'est possible.
*/
class MainActivity : ComponentActivity() {

private var viewModel: MainViewModel? = null
Expand All @@ -42,9 +52,12 @@ class MainActivity : ComponentActivity() {
viewModel?.onPermissionsGranted()
}
granted = hasLocationPermission()
permanentlyDenied = !granted &&
!shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)
}

private var granted by mutableStateOf(false)
private var permanentlyDenied by mutableStateOf(false)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -59,7 +72,11 @@ class MainActivity : ComponentActivity() {
model.onPermissionsGranted()
MainScreen(model)
} else {
PermissionRequest { permissionLauncher.launch(REQUIRED_PERMISSIONS) }
PermissionRequest(
permanentlyDenied = permanentlyDenied,
onRequest = { permissionLauncher.launch(REQUIRED_PERMISSIONS) },
onOpenSettings = ::openAppSettings
)
}
}
}
Expand All @@ -78,22 +95,39 @@ class MainActivity : ComponentActivity() {
super.onPause()
}

private fun openAppSettings() {
startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
)
)
}

private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}

@androidx.compose.runtime.Composable
private fun PermissionRequest(onRequest: () -> Unit) {
private fun PermissionRequest(
permanentlyDenied: Boolean,
onRequest: () -> Unit,
onOpenSettings: () -> Unit
) {
Column(
Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"SatTemps a besoin de la localisation precise (GPS) et de l'etat du telephone " +
"pour lire les satellites, l'heure GNSS et les cellules 5G / LTE."
)
Button(onClick = onRequest) { Text("Autoriser") }
Text(stringResource(R.string.permission_rationale), textAlign = TextAlign.Center)
if (permanentlyDenied) {
Text(stringResource(R.string.permission_denied_twice), textAlign = TextAlign.Center)
Button(onClick = onOpenSettings) {
Text(stringResource(R.string.permission_open_settings))
}
} else {
Button(onClick = onRequest) { Text(stringResource(R.string.permission_allow)) }
}
}
}
65 changes: 57 additions & 8 deletions app/src/main/java/com/mikael/sattemps/MainViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.mikael.sattemps

import android.app.Application
import android.content.Context
import android.os.PowerManager
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.mikael.sattemps.geo.DishPointing
Expand All @@ -22,13 +24,20 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
* Rassemble les sources de mesure (GNSS, boussole, modem, temps de reference) et
* cadence leur rafraichissement selon l'usage : plein regime quand l'ecran est
* visible, ralenti en arriere plan ou en economie de batterie.
*/
class MainViewModel(application: Application) : AndroidViewModel(application) {

private val gnss = GnssRepository(application)
private val compass = CompassRepository(application)
private val cells = CellRepository(application, viewModelScope)
private val timeSync = TimeSyncManager(viewModelScope)
private val scanner = DirectionScanner()
private val powerManager =
application.getSystemService(Context.POWER_SERVICE) as? PowerManager

val gnssState: StateFlow<GnssState> = gnss.state
val compassState: StateFlow<CompassState> = compass.state
Expand All @@ -46,39 +55,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val dishPointings: StateFlow<List<DishPointing>> = _dishPointings.asStateFlow()

private var permissionsGranted = false
private var foreground = false

init {
gnss.onGnssTimeOffset = timeSync::onGnssTimeOffset
gnss.onGnssTimeLost = timeSync::onGnssTimeLost

viewModelScope.launch {
while (isActive) {
_nowMillis.value = timeSync.state.value.nowMillis()
delay(40L)
delay(clockIntervalMillis())
}
}

viewModelScope.launch {
while (isActive) {
gnss.refreshStatus()
val location = gnss.state.value.location
if (location != null) {
compass.updateDeclination(
location.latitude, location.longitude, location.altitude, System.currentTimeMillis()
location.latitude, location.longitude, location.altitude,
System.currentTimeMillis()
)
_dishPointings.value =
GeoSatellites.all(location.latitude, location.longitude, location.altitude)
}
delay(5000L)
delay(if (foreground) STATUS_INTERVAL_MILLIS else STATUS_IDLE_INTERVAL_MILLIS)
}
}

viewModelScope.launch {
while (isActive) {
if (scanner.snapshot().running) {
val dbm = cells.state.value.serving?.dbm
if (dbm != null) scanner.addSample(compass.state.value.trueHeadingDeg, dbm)
val running = scanner.snapshot().running
if (running) {
cells.state.value.serving?.dbm?.let {
scanner.addSample(compass.state.value.trueHeadingDeg, it)
}
_scanState.value = scanner.snapshot()
}
delay(400L)
delay(if (running) SCAN_SAMPLE_INTERVAL_MILLIS else SCAN_IDLE_INTERVAL_MILLIS)
}
}
}
Expand All @@ -91,24 +106,58 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
start()
}

/** Ecran visible : capteurs actifs et cadence nominale. */
fun start() {
foreground = true
compass.start()
}

/** Ecran masque : on relache la boussole, principale consommatrice de batterie. */
fun stop() {
foreground = false
compass.stop()
}

fun forceTimeSync() = timeSync.forceNtpSync()

/**
* Demarre ou arrete la mesure directionnelle 360°, en accelerant la lecture du
* modem pendant la mesure uniquement.
*/
fun toggleScan() {
if (scanner.snapshot().running) scanner.stop() else scanner.start()
if (scanner.snapshot().running) {
scanner.stop()
cells.refreshIntervalMillis = CellRepository.IDLE_INTERVAL_MILLIS
} else {
scanner.start()
cells.refreshIntervalMillis = CellRepository.SCAN_INTERVAL_MILLIS
}
_scanState.value = scanner.snapshot()
}

/**
* Cadence de l'horloge : 40 ms pour une lecture fluide a la milliseconde, ralentie
* en arriere plan ou lorsque le telephone est en economie d'energie.
*/
private fun clockIntervalMillis(): Long = when {
!foreground -> CLOCK_BACKGROUND_INTERVAL_MILLIS
powerManager?.isPowerSaveMode == true -> CLOCK_SAVER_INTERVAL_MILLIS
else -> CLOCK_INTERVAL_MILLIS
}

override fun onCleared() {
gnss.stop()
compass.stop()
super.onCleared()
}

private companion object {
const val CLOCK_INTERVAL_MILLIS = 40L
const val CLOCK_SAVER_INTERVAL_MILLIS = 200L
const val CLOCK_BACKGROUND_INTERVAL_MILLIS = 1000L
const val STATUS_INTERVAL_MILLIS = 5000L
const val STATUS_IDLE_INTERVAL_MILLIS = 30_000L
const val SCAN_SAMPLE_INTERVAL_MILLIS = 400L
const val SCAN_IDLE_INTERVAL_MILLIS = 1000L
}
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/mikael/sattemps/geo/GeoSatellites.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ object GeoSatellites {
GeoSatellite("Hispasat 30W (30.0W)", -30.0)
)

/**
* Azimut, elevation, inclinaison LNB et distance pour viser [satellite] depuis la
* position donnee. L'elevation est negative quand le satellite est sous l'horizon.
*/
fun pointing(satellite: GeoSatellite, latDeg: Double, lonDeg: Double, altitudeM: Double = 0.0): DishPointing {
val lat = Math.toRadians(latDeg)
val lon = Math.toRadians(lonDeg)
Expand Down Expand Up @@ -82,6 +86,7 @@ object GeoSatellites {
.filter { it.visible }
.maxByOrNull { it.elevationDeg }

/** Tous les satellites connus, du plus haut au plus bas sur l'horizon. */
fun all(latDeg: Double, lonDeg: Double, altitudeM: Double = 0.0): List<DishPointing> =
common.map { pointing(it, latDeg, lonDeg, altitudeM) }
.sortedByDescending { it.elevationDeg }
Expand Down
Loading