commit 16c36b36d42b78aa0084c9687a4e107cb33e3004 Author: Fabio Mazza Date: Tue Apr 28 11:01:27 2026 +0200 Rewrite Passaggio logic in Kotlin using java time classes, add test Summary: Convert Passaggio to Kotlin and finally use the correct class for the arrival time Fix sorting in the nearby arrivals, write tests Test Plan: Use both the development build and the stable one. Check arrival times of the same stop in both app, see that they match in order Reviewers: #libre_busto_hackers, valerio.bozzolan Reviewed By: #libre_busto_hackers, valerio.bozzolan Subscribers: lvps, valerio.bozzolan Project Tags: #libre_busto Differential Revision: https://gitpull.it/D229 diff --git a/app/build.gradle b/app/build.gradle index 6b4b9ea..b0e7315 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -59,6 +59,7 @@ android { compileOptions { sourceCompatibility JavaVersion.VERSION_21 targetCompatibility JavaVersion.VERSION_21 + coreLibraryDesugaringEnabled true } kotlin { jvmToolchain 21 @@ -108,6 +109,7 @@ ksp { } dependencies { + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" diff --git a/app/src/main/java/it/reyboz/bustorino/backend/FiveTAPIFetcher.java b/app/src/main/java/it/reyboz/bustorino/backend/FiveTAPIFetcher.java index e0627dc..f7c6d42 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/FiveTAPIFetcher.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/FiveTAPIFetcher.java @@ -112,7 +112,7 @@ public class FiveTAPIFetcher implements ArrivalsFetcher{ for(int j=0;j. - */ - -package it.reyboz.bustorino.backend; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import android.util.Log; - -import java.util.Locale; - -public final class Passaggio implements Comparable, Parcelable { - - private static final int UNKNOWN_TIME = -3; - private static final String DEBUG_TAG = "BusTO-Passaggio"; - - private final String passaggioGTT; - public final int hh,mm; - private @Nullable Integer realtimeDifference; - public final boolean isInRealTime; - public final Source source; - - - /** - * Useless constructor. - * - * //@param TimeGTT time in GTT format (e.g. "11:22*"), already trimmed from whitespace. - */ -// public Passaggio(@NonNull String TimeGTT) { -// this.passaggio = TimeGTT; -// } - - @Override - public String toString() { - return this.passaggioGTT; - } - - - /** - * Constructs a time (passaggio) for the timetable. - * - * @param TimeGTT time in GTT format (e.g. "11:22*"), already trimmed from whitespace. - * @throws IllegalArgumentException if nothing reasonable can be extracted from the string - */ - public Passaggio(@NonNull String TimeGTT, @NonNull Source sorgente) { - passaggioGTT = TimeGTT; - source = sorgente; - String[] parts = TimeGTT.split(":"); - String hh,mm; - boolean realtime; - if(parts.length != 2) { - //throw new IllegalArgumentException("The string " + TimeGTT + " doesn't follow the sacred format of time according to GTT!"); - Log.w(DEBUG_TAG,"The string " + TimeGTT + " doesn't follow the sacred format of time according to GTT!"); - this.hh = UNKNOWN_TIME; - this.mm = UNKNOWN_TIME; - this.isInRealTime = false; - return; - } - hh = parts[0]; - if(parts[1].endsWith("*")) { - mm = parts[1].substring(0, parts[1].length() - 1); - realtime = true; - } else { - mm = parts[1]; - realtime = false; - } - int hour=-3,min=-3; - try { - hour = Integer.parseInt(hh); - min = Integer.parseInt(mm); - } catch (NumberFormatException ex){ - Log.w(DEBUG_TAG,"Cannot convert passaggio into hour and minutes"); - hour = UNKNOWN_TIME; - min = UNKNOWN_TIME; - realtime = false; - } finally { - this.hh = hour; - this.mm = min; - this.isInRealTime = realtime; - - } - } - - public Passaggio(int hour, int minutes, boolean realtime, Source sorgente){ - this.hh = hour; - this.mm = minutes; - this.isInRealTime = realtime; - if (!realtime) realtimeDifference = 0; - this.source = sorgente; - //Build the passaggio string - StringBuilder sb = new StringBuilder(); - sb.append(hour).append(":").append(minutes); - if(realtime) sb.append("*"); - this.passaggioGTT = sb.toString(); - } - - public static String createPassaggioGTT(String timeInput, boolean realtime){ - final String time = timeInput.trim(); - if(time.contains("*")){ - if(realtime) return time; - else return time.substring(0,time.length()-1); - } else{ - if(realtime) return time.concat("*"); - else return time; - } - } - public Passaggio(int numSeconds, boolean realtime, int timeDiff, Source source){ - int minutes = numSeconds / 60; - int hours = minutes / 60; - //this.hh = hours; - this.mm = minutes - hours*60; - this.hh = hours % 24; - this.realtimeDifference = timeDiff/60; - this.isInRealTime = realtime; - this.source = source; - this.passaggioGTT = makePassaggioGTT(this.hh, this.mm, this.isInRealTime); - } - - private static String makePassaggioGTT(int hour, int minutes, boolean realtime){ - StringBuilder sb = new StringBuilder(); - sb.append(String.format(Locale.ITALIAN,"%02d", hour)).append(":").append(String.format(Locale.ITALIAN,"%02d", minutes)); - if(realtime) sb.append("*"); - return sb.toString(); - } - - @Override - public int compareTo(@NonNull Passaggio other) { - if(this.hh == UNKNOWN_TIME || other.hh == UNKNOWN_TIME) - return 0; - else { - int diff = getMinutesDiff(other); - - // we should take into account if one is in real time and the other isn't, shouldn't we? - if (other.isInRealTime) { - diff+=2; - } - if (this.isInRealTime) { - diff -=2; - } - - return diff; - } - } - public int getMinutesDiff(Passaggio other){ - int diff = this.hh - other.hh; - // an attempt to correctly sort arrival times around midnight (e.g. 23.59 should come before 00.01) - if (diff > 12) { // untested - diff -= 24; - } else if (diff < -12) { - diff += 24; - } - - diff *= 60; - - diff += this.mm - other.mm; - return diff; - } - - - protected Passaggio(Parcel in) { - passaggioGTT = in.readString(); - hh = in.readInt(); - mm = in.readInt(); - if (in.readByte() == 0) { - realtimeDifference = null; - } else { - realtimeDifference = in.readInt(); - } - isInRealTime = in.readByte() != 0; - source = Source.valueOf(in.readString()); - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(passaggioGTT); - dest.writeInt(hh); - dest.writeInt(mm); - if (realtimeDifference == null) { - dest.writeByte((byte) 0); - } else { - dest.writeByte((byte) 1); - dest.writeInt(realtimeDifference); - } - dest.writeByte((byte) (isInRealTime ? 1 : 0)); - dest.writeString(source.name()); - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = new Creator() { - @Override - public Passaggio createFromParcel(Parcel in) { - return new Passaggio(in); - } - - @Override - public Passaggio[] newArray(int size) { - return new Passaggio[size]; - } - }; - -// -// @Override -// public String toString() { -// String resultString = (this.hh).concat(":").concat(this.mm); -// if(this.isInRealTime) { -// return resultString.concat("*"); -// } else { -// return resultString; -// } -// } - public enum Source{ - FiveTAPI,GTTJSON,FiveTScraper,MatoAPI, UNDETERMINED - } -} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Passaggio.kt b/app/src/main/java/it/reyboz/bustorino/backend/Passaggio.kt new file mode 100644 index 0000000..926ebb4 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/backend/Passaggio.kt @@ -0,0 +1,257 @@ +/* + BusTO (backend components) + Copyright (C) 2016 Ludovico Pavesi + Copyright (C) 2026 Fabio Mazza + + 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 . + */ +package it.reyboz.bustorino.backend + +import android.os.Parcel +import android.os.Parcelable +import android.util.Log + +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.* + +data class Passaggio( + val arrivalTime: ZonedDateTime, + val isInRealTime: Boolean, + @JvmField + val source: Source, + val realtimeDifference: Int? = null, +) : Comparable, Parcelable { + private val passaggioGTT: String = arrivalTime.format(DATEFORMATTER) + (if (isInRealTime) "*" else "") + + override fun toString(): String { + return this.passaggioGTT + } + + + /*override fun compareTo(other: Passaggio?): Int { + if (this.hh == UNKNOWN_TIME || other.hh == UNKNOWN_TIME) return 0 + else { + var diff = getMinutesDiff(other) + + // we should take into account if one is in real time and the other isn't, shouldn't we? + if (other.isInRealTime) { + diff += 2 + } + if (this.isInRealTime) { + diff -= 2 + } + + return diff + } + } + + */ + override fun compareTo(other: Passaggio): Int { + //DO NOT PUT REAL TIME FIRST (PassaggiSorter exists for this reason) + /*if (isInRealTime != other.isInRealTime) { + return if (isInRealTime) -1 else 1 + } + + */ + return arrivalTime.compareTo(other.arrivalTime) + } + + + /*fun getMinutesDiff(other: Passaggio): Int { + var diff = this.hh - other.hh + // an attempt to correctly sort arrival times around midnight (e.g. 23.59 should come before 00.01) + if (diff > 12) { // untested + diff -= 24 + } else if (diff < -12) { + diff += 24 + } + + diff *= 60 + + diff += this.mm - other.mm + return diff + } + + */ + /** + * Calculate difference in minutes, positive is this arrives after the other one, negative if it arrives before + */ + fun getDifferenceMinutes(other: Passaggio): Long { + val res = ChronoUnit.MINUTES.between(other.arrivalTime, this.arrivalTime) + return res + } + + + enum class Source { + FiveTAPI, GTTJSON, FiveTScraper, MatoAPI, UNDETERMINED + } + + constructor(parcel: Parcel) : this( + arrivalTime = ZonedDateTime.parse( + parcel.readString(), + DateTimeFormatter.ISO_ZONED_DATE_TIME + ), + isInRealTime = parcel.readByte() != 0.toByte(), + source = Source.valueOf(parcel.readString()?: Source.UNDETERMINED.name) ?: Source.FiveTAPI, + realtimeDifference = parcel.readValue(Int::class.java.classLoader) as? Int + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(arrivalTime.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)) + parcel.writeByte(if (isInRealTime) 1 else 0) + parcel.writeString(source.name) + parcel.writeValue(realtimeDifference) + } + + override fun describeContents(): Int = 0 + + companion object { + private val UNKNOWN_TIME = -3 + private const val DEBUG_TAG = "BusTO-Passaggio" + + @JvmField + val CREATOR = object: Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): Passaggio = Passaggio(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + + @JvmStatic + private fun parseHourMin(hour: Int, minutes: Int, slackMin: Long = 30): ZonedDateTime { + val zona = ZoneId.of("Europe/Rome") + val timeNow = ZonedDateTime.now(zona) + val newTime = LocalTime.of(hour, minutes) + + var possibleTime = ZonedDateTime.of(LocalDate.now(zona), newTime, zona) + + // Se è già passato (o è esattamente adesso e vuoi escluderlo), vado al giorno dopo + if (possibleTime.isBefore(timeNow.minusMinutes(slackMin))) { + possibleTime = possibleTime.plusDays(1) + } + + return possibleTime + } + + @JvmStatic + private val DATEFORMATTER = DateTimeFormatter.ofPattern("HH:mm") + /** + * Constructs a time (passaggio) for the timetable. + * + * @param TimeGTT time in GTT format (e.g. "11:22*"), already trimmed from whitespace. + * @throws IllegalArgumentException if nothing reasonable can be extracted from the string + */ + @JvmStatic + fun newInstance(TimeGTT: String, sorgente: Source) : Passaggio? { + val parts = TimeGTT.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val hh: String + val mm: String + var realtime: Boolean + if (parts.size != 2) { + //throw new IllegalArgumentException("The string " + TimeGTT + " doesn't follow the sacred format of time according to GTT!"); + Log.w(DEBUG_TAG, "The string $TimeGTT doesn't follow the sacred format of time according to GTT!") + return null; + } + hh = parts[0] + if (parts[1].endsWith("*")) { + mm = parts[1].substring(0, parts[1].length - 1) + realtime = true + } else { + mm = parts[1] + realtime = false + } + var time: ZonedDateTime? = null + try { + val hour = hh.toInt() + val min = mm.toInt() + time = parseHourMin(hour, min) + } catch (ex: Exception) { + Log.w(DEBUG_TAG, "Cannot convert passaggio into hour and minutes:\n$ex") + return null + } + return Passaggio(time, realtime, sorgente) + } + + + /** + * General constructor for the case hour & minutes + */ + @JvmStatic + fun newInstance(hour: Int, minutes: Int, realtime: Boolean, sorgente: Source, realtimeDifference: Int?): Passaggio? { + /*this.hh = hour + this.mm = minutes + this.isInRealTime = realtime + if (!realtime) realtimeDifference = 0 + this.source = sorgente + //Build the passaggio string + val sb = StringBuilder() + sb.append(hour).append(":").append(minutes) + if (realtime) sb.append("*") + this.passaggioGTT = sb.toString() + + */ + var time: ZonedDateTime? = null + try{ + time = parseHourMin(hour, minutes) + } catch (ex: Exception) { + Log.e(DEBUG_TAG, "Cannot parse hour $hour and minutes:$minutes into time:\n$ex") + return null + } + return Passaggio(time, realtime, sorgente, realtimeDifference) + } + + @JvmStatic + fun newInstance(numSeconds: Int, realtime: Boolean, timeDiffSeconds: Int, source: Source) : Passaggio? { + var minutes: Int = numSeconds / 60 + var hours :Int = minutes / 60 + //this.hh = hours; + minutes -= hours * 60 + hours %= 24 + var timeDiffMins:Int = timeDiffSeconds / 60 + /* + this. + this.isInRealTime = realtime + this.source = source + this.passaggioGTT = makePassaggioGTT(this.hh, this.mm, this.isInRealTime) + + */ + return newInstance(hours, minutes,realtime, source, timeDiffMins) + } + + @JvmStatic + fun createPassaggioGTTString(timeInput: String, realtime: Boolean): String { + val time = timeInput.trim { it <= ' ' } + if (time.contains("*")) { + if (realtime) return time + else return time.substring(0, time.length - 1) + } else { + if (realtime) return time + "*" + else return time + } + } + + private fun makePassaggioGTT(hour: Int, minutes: Int, realtime: Boolean): String { + val sb = StringBuilder() + sb.append(String.format(Locale.ITALIAN, "%02d", hour)).append(":") + .append(String.format(Locale.ITALIAN, "%02d", minutes)) + if (realtime) sb.append("*") + return sb.toString() + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Route.java b/app/src/main/java/it/reyboz/bustorino/backend/Route.java index 8a13092..42725f4 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/Route.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Route.java @@ -156,17 +156,28 @@ public class Route implements Comparable, Parcelable { return this.stopsList; } + /** + * Add internally a passaggio if it is not null + * @param passaggio + * @return 1 if it is added, 0 if not + */ + private int addPassaggioCheck(Passaggio passaggio){ + if (passaggio!=null) { + this.passaggi.add(passaggio); + return 1; + } else return 0; + } /** * Adds a time (passaggio) to the timetable for this route * * @param TimeGTT time in GTT format (e.g. "11:22*") */ - public void addPassaggio(String TimeGTT, Passaggio.Source source) { - this.passaggi.add(new Passaggio(TimeGTT, source)); + public int addPassaggio(String TimeGTT, Passaggio.Source source) { + return addPassaggioCheck(Passaggio.newInstance(TimeGTT, source)); } //Overloaded - public void addPassaggio(int hour, int minutes, boolean realtime, Passaggio.Source source) { - this.passaggi.add(new Passaggio(hour, minutes, realtime, source)); + public int addPassaggio(int hour, int minutes, boolean realtime, Passaggio.Source source) { + return addPassaggioCheck(Passaggio.newInstance(hour, minutes, realtime, source, null)); } public static Route.Type getTypeFromSymbol(String route) { diff --git a/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt b/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt index 55545dc..9d0de14 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/mato/MatoAPIFetcher.kt @@ -247,10 +247,19 @@ open class MatoAPIFetcher( val scheduledTime = stoptime.getInt("scheduledArrival") val realtimeTime = stoptime.getInt("realtimeArrival") val realtime = stoptime.getBoolean("realtime") - passages.add( + + val passaggio = Passaggio.newInstance(realtimeTime, realtime, + (realtimeTime-scheduledTime), Passaggio.Source.MatoAPI) + + passaggio?.let{ + passages.add(it) + } + /*passages.add( Passaggio(realtimeTime,realtime, realtimeTime-scheduledTime, Passaggio.Source.MatoAPI) ) + + */ } var routeType = Route.Type.UNKNOWN if (gtfsId[gtfsId.length-1] == 'E') diff --git a/app/src/main/java/it/reyboz/bustorino/util/PassaggiSorter.java b/app/src/main/java/it/reyboz/bustorino/util/PassaggiSorter.java index 9fc4adf..30f1dfb 100644 --- a/app/src/main/java/it/reyboz/bustorino/util/PassaggiSorter.java +++ b/app/src/main/java/it/reyboz/bustorino/util/PassaggiSorter.java @@ -11,28 +11,11 @@ public class PassaggiSorter implements Comparator { @Override public int compare(Passaggio p1, Passaggio p2) { - if (p1.isInRealTime){ - if(p2.isInRealTime){ - //compare times - return p1.getMinutesDiff(p2); - } - else { - return -2; - } - } else{ - if(p2.isInRealTime){ - // other should come first - return 2; - } else return p1.getMinutesDiff(p2); + if (p1.isInRealTime() != p2.isInRealTime()){ + if(p1.isInRealTime()) return -1; + else return 1; } + return p1.getArrivalTime().compareTo(p2.getArrivalTime()); } - @Override - public boolean equals(Object o) { - boolean equal= this.equals(o); - if (equal) return true; - else{ - return o instanceof PassaggiSorter; - } - } } diff --git a/app/src/main/java/it/reyboz/bustorino/util/RoutePositionSorter.java b/app/src/main/java/it/reyboz/bustorino/util/RoutePositionSorter.java index 2eb0f23..7012d7d 100644 --- a/app/src/main/java/it/reyboz/bustorino/util/RoutePositionSorter.java +++ b/app/src/main/java/it/reyboz/bustorino/util/RoutePositionSorter.java @@ -23,6 +23,7 @@ import android.util.Log; import it.reyboz.bustorino.backend.*; +import java.time.ZonedDateTime; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -54,12 +55,16 @@ public class RoutePositionSorter implements Comparator> { } else { Collections.sort(passaggi1); Collections.sort(passaggi2); - int deltaOre = passaggi1.get(0).hh-passaggi2.get(0).hh; + /*int deltaOre = passaggi1.get(0).hh-passaggi2.get(0).hh; if(deltaOre>12) deltaOre -= 24; else if (deltaOre<-12) deltaOre += 24; delta+=deltaOre*60 + passaggi1.get(0).mm-passaggi2.get(0).mm; + + */ + + delta = (int) passaggi1.get(0).getDifferenceMinutes(passaggi2.get(0)); } delta += (int)((dist1 -dist2)*minutialmetro*distancemultiplier); return delta; diff --git a/app/src/test/java/it/reyboz/bustorino/util/ArrivalTimesTest.java b/app/src/test/java/it/reyboz/bustorino/util/ArrivalTimesTest.java new file mode 100644 index 0000000..87652da --- /dev/null +++ b/app/src/test/java/it/reyboz/bustorino/util/ArrivalTimesTest.java @@ -0,0 +1,29 @@ +package it.reyboz.bustorino.util; + +import it.reyboz.bustorino.backend.Passaggio; +import org.junit.Test; +import static org.junit.Assert.*; +public class ArrivalTimesTest { + + @Test + public void arrivalTimesTest(){ + Passaggio pass1 = Passaggio.newInstance(20,12,true, Passaggio.Source.GTTJSON,null); + + Passaggio pass2 = Passaggio.newInstance(1,12,true, Passaggio.Source.GTTJSON,null); + + assertNotNull(pass1); + assertNotNull(pass2); + assertTrue(pass1.compareTo(pass2) < 0); + } + + @Test + public void arrivalTimesWithTimeGTT(){ + Passaggio pass1 = Passaggio.newInstance("23:10*", Passaggio.Source.GTTJSON); + + Passaggio pass2 = Passaggio.newInstance(1,12,true, Passaggio.Source.GTTJSON,null); + + assertNotNull(pass1); + assertNotNull(pass2); + assertTrue(pass2.getDifferenceMinutes(pass1) > 0); + } +}