|
@@ -0,0 +1,312 @@
|
|
|
|
|
+package fr.barteam.watchosm.data
|
|
|
|
|
+
|
|
|
|
|
+import fr.barteam.watchosm.domain.Maneuver
|
|
|
|
|
+import fr.barteam.watchosm.domain.NavigationState
|
|
|
|
|
+import fr.barteam.watchosm.domain.TurnInstruction
|
|
|
|
|
+import java.util.Locale
|
|
|
|
|
+
|
|
|
|
|
+/** Règle d'appariement : un type de manœuvre et ses motifs EN/FR. */
|
|
|
|
|
+private data class ManeuverRule(val maneuver: Maneuver, val patterns: List<Regex>)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Extrait l'état de navigation OsmAnd à partir des extras d'une notification.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Les champs sont déjà détachés du type Android pour rester testables en JVM
|
|
|
|
|
+ * pure (voir les tests unitaires du parseur).
|
|
|
|
|
+ */
|
|
|
|
|
+data class OsmAndNotificationPayload(
|
|
|
|
|
+ val title: CharSequence?,
|
|
|
|
|
+ val text: CharSequence?,
|
|
|
|
|
+ val bigText: CharSequence?,
|
|
|
|
|
+ val subText: CharSequence?,
|
|
|
|
|
+ val infoText: CharSequence?,
|
|
|
|
|
+ val category: String?,
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Parseur défensif du contenu de navigation d'OsmAnd.
|
|
|
|
|
+ *
|
|
|
|
|
+ * OsmAnd ne publie aucun contrat de format pour le texte de sa notification de
|
|
|
|
|
+ * navigation permanente : le contenu varie avec la langue, le profil et les
|
|
|
|
|
+ * réglages de voice prompts. Le parseur travaille donc ainsi :
|
|
|
|
|
+ *
|
|
|
|
|
+ * 1. corpus = concaténation des extras textuels, normalisé (bas de casse,
|
|
|
|
|
+ * accents ASCII, espaces réduits) — la normalisation est caractère à
|
|
|
|
|
+ * caractère afin que les indices restent valables sur l'original ;
|
|
|
|
|
+ * 2. détection des marqueurs (pause, manœuvre) par dictionnaire de motifs
|
|
|
|
|
+ * EN/FR, ordonnés du plus spécifique au plus général (un « keep left »
|
|
|
|
|
+ * doit être classé avant un simple « left ») ;
|
|
|
|
|
+ * 3. extraction des distances/heures par expressions régulières, avec un
|
|
|
|
|
+ * garde-fou contre les unités de vitesse (« km/h » n'est pas une distance) ;
|
|
|
|
|
+ * 4. acceptation du résultat seulement si un marqueur de navigation a été
|
|
|
|
|
+ * trouvé, ou si la notification porte la catégorie navigation. Sans cette
|
|
|
|
|
+ * garde, une notification d'enregistrement de trace OsmAnd (« 12,5 km »)
|
|
|
|
|
+ * serait prise pour une navigation active.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Chaque heuristique est isolée dans une méthode privée : face à un format
|
|
|
|
|
+ * OsmAnd non reconnu, on ajuste un motif sans toucher au reste de la chaîne.
|
|
|
|
|
+ */
|
|
|
|
|
+class OsmAndNotificationParser {
|
|
|
|
|
+
|
|
|
|
|
+ fun parse(payload: OsmAndNotificationPayload): NavigationState? {
|
|
|
|
|
+ val parts = listOfNotNull(
|
|
|
|
|
+ payload.title,
|
|
|
|
|
+ payload.text,
|
|
|
|
|
+ payload.bigText,
|
|
|
|
|
+ payload.subText,
|
|
|
|
|
+ payload.infoText,
|
|
|
|
|
+ ).map { it.toString() }
|
|
|
|
|
+ if (parts.isEmpty()) return null
|
|
|
|
|
+
|
|
|
|
|
+ val original = parts.joinToString(SEPARATOR)
|
|
|
|
|
+ val corpus = normalize(original)
|
|
|
|
|
+
|
|
|
|
|
+ val isCategoryNavigation = payload.category == CATEGORY_NAVIGATION
|
|
|
|
|
+ val isPaused = PAUSED_PATTERNS.any { it.containsMatchIn(corpus) }
|
|
|
|
|
+ val match = findManeuver(corpus)
|
|
|
|
|
+
|
|
|
|
|
+ // Garde-fou d'acceptation : voir la doc de classe.
|
|
|
|
|
+ if (match == null && !isPaused && !isCategoryNavigation) return null
|
|
|
|
|
+
|
|
|
|
|
+ val distances = DISTANCE_PATTERN.findAll(original)
|
|
|
|
|
+ .map { DistanceToken(it.range, toMeters(it.groupValues[1], it.groupValues[2])) }
|
|
|
|
|
+ .toList()
|
|
|
|
|
+
|
|
|
|
|
+ val maneuverDistance = if (match != null) {
|
|
|
|
|
+ maneuverDistance(distances, match.range)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ val speeds = extractSpeeds(corpus)
|
|
|
|
|
+
|
|
|
|
|
+ return NavigationState(
|
|
|
|
|
+ isActive = true,
|
|
|
|
|
+ isPaused = isPaused,
|
|
|
|
|
+ instruction = match?.let {
|
|
|
|
|
+ TurnInstruction(
|
|
|
|
|
+ maneuver = it.maneuver,
|
|
|
|
|
+ distanceMeters = maneuverDistance,
|
|
|
|
|
+ streetName = extractStreet(original, it),
|
|
|
|
|
+ rawText = original,
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ remainingDistanceMeters = remainingDistance(distances, maneuverDistance),
|
|
|
|
|
+ remainingTimeMinutes = extractRemainingMinutes(corpus),
|
|
|
|
|
+ arrivalTimeLabel = extractArrivalLabel(original),
|
|
|
|
|
+ speedKmh = speeds.firstOrNull(),
|
|
|
|
|
+ speedLimitKmh = speeds.getOrNull(1),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------------------------------------------------------------- marqueurs
|
|
|
|
|
+
|
|
|
|
|
+ private data class ManeuverMatch(val maneuver: Maneuver, val range: IntRange)
|
|
|
|
|
+
|
|
|
|
|
+ private fun findManeuver(corpus: String): ManeuverMatch? {
|
|
|
|
|
+ for (rule in MANEUVER_RULES) {
|
|
|
|
|
+ for (pattern in rule.patterns) {
|
|
|
|
|
+ val found = pattern.find(corpus)
|
|
|
|
|
+ if (found != null) return ManeuverMatch(rule.maneuver, found.range)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // --------------------------------------------------------------- distances
|
|
|
|
|
+
|
|
|
|
|
+ private data class DistanceToken(val range: IntRange, val meters: Int)
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Distance avant la manœuvre : dernier jeton situé avant le mot-clé de la
|
|
|
|
|
+ * manœuvre (« In 300 m turn right »), sinon premier jeton du corpus
|
|
|
|
|
+ * (« Turn right in 300 m »), sinon null.
|
|
|
|
|
+ */
|
|
|
|
|
+ private fun maneuverDistance(distances: List<DistanceToken>, maneuverRange: IntRange): Int? {
|
|
|
|
|
+ if (distances.isEmpty()) return null
|
|
|
|
|
+ val before = distances.filter { it.range.last < maneuverRange.first }
|
|
|
|
|
+ return before.lastOrNull()?.meters ?: distances.first().meters
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Distance restante sur le trajet : tous les jetons sauf celui de la
|
|
|
|
|
+ * manœuvre, en prenant le plus grand (« 300 m » de manœuvre ne doit pas
|
|
|
|
|
+ * écraser « 5 km » de trajet). Null s'il n'y a qu'un seul jeton.
|
|
|
|
|
+ */
|
|
|
|
|
+ private fun remainingDistance(distances: List<DistanceToken>, maneuverDistance: Int?): Int? {
|
|
|
|
|
+ val others = distances.filter { it.meters != maneuverDistance }
|
|
|
|
|
+ return others.maxOfOrNull { it.meters }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun toMeters(value: String, unit: String): Int {
|
|
|
|
|
+ val number = value.replace(',', '.').toDoubleOrNull() ?: return 0
|
|
|
|
|
+ return when (unit.lowercase(Locale.ROOT)) {
|
|
|
|
|
+ "km" -> (number * 1000).toInt()
|
|
|
|
|
+ "mi" -> (number * 1609.34).toInt()
|
|
|
|
|
+ else -> number.toInt()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------ temps
|
|
|
|
|
+
|
|
|
|
|
+ private fun extractRemainingMinutes(corpus: String): Int? {
|
|
|
|
|
+ val hoursAndMinutes = HOURS_MINUTES_PATTERN.find(corpus)
|
|
|
|
|
+ if (hoursAndMinutes != null) {
|
|
|
|
|
+ val hours = hoursAndMinutes.groupValues[1].toInt()
|
|
|
|
|
+ val minutes = hoursAndMinutes.groupValues[2].toInt()
|
|
|
|
|
+ return hours * 60 + minutes
|
|
|
|
|
+ }
|
|
|
|
|
+ return MINUTES_PATTERN.find(corpus)?.groupValues?.get(1)?.toInt()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fun extractArrivalLabel(original: String): String? =
|
|
|
|
|
+ ARRIVAL_PATTERN.find(original)?.groupValues?.get(1)
|
|
|
|
|
+
|
|
|
|
|
+ private fun extractSpeeds(corpus: String): List<Int> =
|
|
|
|
|
+ SPEED_PATTERN.findAll(corpus)
|
|
|
|
|
+ .mapNotNull { it.groupValues[1].replace(',', '.').toDoubleOrNull() }
|
|
|
|
|
+ .map { it.toInt() }
|
|
|
|
|
+ .toList()
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------- rue
|
|
|
|
|
+
|
|
|
|
|
+ private val streetSeparators = charArrayOf('·', '\n', '|', ';', '(', ',')
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Rue après le mot-clé de manœuvre : sous-chaîne d'origine (accents
|
|
|
|
|
+ * conservés) jusqu'au séparateur, débarrassée des prépositions de liaison
|
|
|
|
|
+ * (« onto », « sur », …). La normalisation est caractère à caractère, donc
|
|
|
|
|
+ * les indices calculés dessus restent valides sur l'original.
|
|
|
|
|
+ */
|
|
|
|
|
+ private fun extractStreet(original: String, match: ManeuverMatch): String? {
|
|
|
|
|
+ // On saute le reste du mot-clé : un motif comme « \barriv » ne couvre
|
|
|
|
|
+ // pas tout le mot et laisserait un fragment (« e à destination »).
|
|
|
|
|
+ var from = match.range.last + 1
|
|
|
|
|
+ while (from < original.length && !original[from].isWhitespace()) from++
|
|
|
|
|
+ if (from >= original.length) return null
|
|
|
|
|
+ var candidate = original.substring(from)
|
|
|
|
|
+ val cut = candidate.indexOfAny(streetSeparators)
|
|
|
|
|
+ if (cut >= 0) candidate = candidate.substring(0, cut)
|
|
|
|
|
+ candidate = candidate.trim()
|
|
|
|
|
+
|
|
|
|
|
+ // Élimine les prépositions de liaison (au plus trois d'affilée).
|
|
|
|
|
+ repeat(LINKING_WORDS_MAX) {
|
|
|
|
|
+ val first = candidate.split(' ', '\t').firstOrNull() ?: return@repeat
|
|
|
|
|
+ if (first.lowercase(Locale.FRANCE) in LINKING_WORDS) {
|
|
|
|
|
+ candidate = candidate.substring(first.length).trim()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return@repeat
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (candidate.isBlank()) return null
|
|
|
|
|
+ return candidate.take(MAX_STREET_LENGTH).trimEnd()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ----------------------------------------------------------- normalisation
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Bas de casse + ASCII, un caractère pour un caractère (les espaces sont
|
|
|
|
|
+ * conservés y compris en double) : les indices calculés sur le corpus
|
|
|
|
|
+ * restent donc directement utilisables sur la chaîne d'origine.
|
|
|
|
|
+ */
|
|
|
|
|
+ private fun normalize(text: String): String {
|
|
|
|
|
+ val builder = StringBuilder(text.length)
|
|
|
|
|
+ for (char in text) {
|
|
|
|
|
+ val lower = char.lowercaseChar()
|
|
|
|
|
+ val folded = ACCENT_FOLD[lower] ?: lower
|
|
|
|
|
+ builder.append(if (folded.isWhitespace()) ' ' else folded)
|
|
|
|
|
+ }
|
|
|
|
|
+ return builder.toString()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ companion object {
|
|
|
|
|
+ /** Identifiants de paquets OsmAnd (payant, gratuit, ancien). */
|
|
|
|
|
+ val OSMAND_PACKAGES = setOf("net.osmand.plus", "net.osmand.free", "net.osmand")
|
|
|
|
|
+
|
|
|
|
|
+ const val CATEGORY_NAVIGATION = "navigation"
|
|
|
|
|
+
|
|
|
|
|
+ /** Séparateur entre les segments textuels de la notification. */
|
|
|
|
|
+ private const val SEPARATOR = "\n"
|
|
|
|
|
+
|
|
|
|
|
+ private const val MAX_STREET_LENGTH = 60
|
|
|
|
|
+ private const val LINKING_WORDS_MAX = 3
|
|
|
|
|
+
|
|
|
|
|
+ private val LINKING_WORDS = setOf(
|
|
|
|
|
+ // EN
|
|
|
|
|
+ "onto", "on", "to", "in", "for", "via", "the", "and",
|
|
|
|
|
+ // FR
|
|
|
|
|
+ "sur", "vers", "dans", "par", "au", "aux", "la", "le", "les", "et", "à",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // Motifs appliqués sur le corpus normalisé (bas de casse, sans accents).
|
|
|
|
|
+
|
|
|
|
|
+ private val PAUSED_PATTERNS = listOf(
|
|
|
|
|
+ Regex("""\bpaus(e|ed|ing)\b"""),
|
|
|
|
|
+ Regex("""en pause"""),
|
|
|
|
|
+ Regex("""navigation suspendue"""),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ private val MANEUVER_RULES = maneuverRules(
|
|
|
|
|
+ Maneuver.UTURN to listOf("demi-tour", """u-?turn""", "turn around"),
|
|
|
|
|
+ Maneuver.ARRIVE to listOf("""\barriv""", "destination reached"),
|
|
|
|
|
+ Maneuver.ROUNDABOUT to listOf(
|
|
|
|
|
+ "roundabout", """rond[-\s]?point""", "carrefour amenage", "traffic circle",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.SHARP_LEFT to listOf(
|
|
|
|
|
+ "sharp left", "hard left", "virement a gauche", "virag\\w* a gauche",
|
|
|
|
|
+ "fortement a gauche", "gauche forte",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.SHARP_RIGHT to listOf(
|
|
|
|
|
+ "sharp right", "hard right", "virement a droite", "virag\\w* a droite",
|
|
|
|
|
+ "fortement a droite", "droite forte",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.SLIGHT_LEFT to listOf(
|
|
|
|
|
+ "slight left", "keep left", "bear left",
|
|
|
|
|
+ """leg\w* a gauche""", "gardez a gauche", "gauche leg",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.SLIGHT_RIGHT to listOf(
|
|
|
|
|
+ "slight right", "keep right", "bear right",
|
|
|
|
|
+ """leg\w* a droite""", "gardez a droite", "droite leg",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.LEFT to listOf("turn left", "a gauche", "left onto", "left into"),
|
|
|
|
|
+ Maneuver.RIGHT to listOf("turn right", "a droite", "right onto", "right into"),
|
|
|
|
|
+ Maneuver.CONTINUE to listOf(
|
|
|
|
|
+ "continue", "tout droit", "straight", "ahead", "poursuivez", "restez",
|
|
|
|
|
+ ),
|
|
|
|
|
+ Maneuver.DEPART to listOf("""\bhead\b""", "partez", "depart"),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ /** L'ordre d'appel est l'ordre de spécificité : voir [MANEUVER_RULES]. */
|
|
|
|
|
+ private fun maneuverRules(
|
|
|
|
|
+ vararg rules: Pair<Maneuver, List<String>>,
|
|
|
|
|
+ ): List<ManeuverRule> = rules.map { (maneuver, patterns) ->
|
|
|
|
|
+ ManeuverRule(maneuver, patterns.map { Regex(it) })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Expressions appliquées sur la chaîne d'origine pour l'affichage
|
|
|
|
|
+ // (rue et heure conservent leurs accents).
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * « km/h » est exclu explicitement : sans ce garde-fou, « 83 km/h »
|
|
|
|
|
+ * serait lu comme une distance de 83 km.
|
|
|
|
|
+ */
|
|
|
|
|
+ private val DISTANCE_PATTERN = Regex("""(\d+(?:[.,]\d+)?)\s*(km|m|mi)\b(?!\s*/)""")
|
|
|
|
|
+
|
|
|
|
|
+ private val HOURS_MINUTES_PATTERN = Regex("""(\d{1,2})\s*(?:h|hr)(?:eure)?s?\s*(\d{1,2})\s*min""")
|
|
|
|
|
+ private val MINUTES_PATTERN = Regex("""(\d{1,3})\s*min\b""")
|
|
|
|
|
+ private val ARRIVAL_PATTERN = Regex("""\b(\d{1,2}:\d{2})\b""")
|
|
|
|
|
+ private val SPEED_PATTERN = Regex("""(\d{1,3}(?:[.,]\d+)?)\s*km\s*/\s*h""")
|
|
|
|
|
+
|
|
|
|
|
+ /** Remplacement accent -> ASCII, un caractère pour un caractère. */
|
|
|
|
|
+ private val ACCENT_FOLD: Map<Char, Char> = buildMap {
|
|
|
|
|
+ put('à', 'a'); put('á', 'a'); put('â', 'a'); put('ä', 'a'); put('ã', 'a'); put('å', 'a')
|
|
|
|
|
+ put('è', 'e'); put('é', 'e'); put('ê', 'e'); put('ë', 'e')
|
|
|
|
|
+ put('ì', 'i'); put('í', 'i'); put('î', 'i'); put('ï', 'i')
|
|
|
|
|
+ put('ò', 'o'); put('ó', 'o'); put('ô', 'o'); put('ö', 'o'); put('õ', 'o')
|
|
|
|
|
+ put('ù', 'u'); put('ú', 'u'); put('û', 'u'); put('ü', 'u')
|
|
|
|
|
+ put('ç', 'c'); put('ñ', 'n'); put('ý', 'y'); put('ÿ', 'y')
|
|
|
|
|
+ put('œ', 'o'); put('æ', 'a'); put('ß', 's')
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|