diff --git a/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java b/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java --- a/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java @@ -55,7 +55,7 @@ //.add(R.id.fragment_container_view, LinesDetailFragment.class, // LinesDetailFragment.Companion.makeArgs("gtt:4U")) - .add(R.id.fragment_container_view, MapLibreFragment.class, null) + .add(R.id.fragment_container_view, AlertsFragment.class, null) .commit(); } } diff --git a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java --- a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java @@ -40,6 +40,8 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProvider; +import androidx.lifecycle.viewmodel.internal.ViewModelProviders; import androidx.preference.PreferenceManager; import androidx.work.WorkInfo; @@ -55,6 +57,7 @@ import it.reyboz.bustorino.data.gtfs.GtfsDatabase; import it.reyboz.bustorino.fragments.*; import it.reyboz.bustorino.middleware.GeneralActivity; +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel; import static it.reyboz.bustorino.backend.utils.getBusStopIDFromUri; import static it.reyboz.bustorino.backend.utils.openIceweasel; @@ -71,6 +74,7 @@ private boolean showingMainFragmentFromOther = false; private boolean onCreateComplete = false; + private ServiceAlertsViewModel serviceAlertsViewModel; private final OnBackPressedCallback callback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { @@ -84,6 +88,8 @@ super.onCreate(savedInstanceState); Log.d(DEBUG_TAG, "onCreate, savedInstanceState is: "+savedInstanceState); setContentView(R.layout.activity_principal); + serviceAlertsViewModel = new ViewModelProvider(this).get(ServiceAlertsViewModel.class); + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { getWindow().setNavigationBarContrastEnforced(false); } @@ -101,7 +107,6 @@ else Log.w(DEBUG_TAG, "NO ACTION BAR"); mToolbar.setOnMenuItemClickListener(new ToolbarItemClickListener(this)); - mDrawer = findViewById(R.id.drawer_layout); drawerToggle = setupDrawerToggle(mToolbar); @@ -300,13 +305,19 @@ - //check if first run activity (IntroActivity) has been started once or not final SharedPreferences theShPr = getMainSharedPreferences(); boolean hasIntroRun = theShPr.getBoolean(PreferencesHolder.PREF_INTRO_ACTIVITY_RUN,false); if(!hasIntroRun){ startIntroductionActivity(); } + serviceAlertsViewModel.getLastTimeRunningDownload().observe(this, (timeRunning) -> { + if (timeRunning != null) { + Log.d(DEBUG_TAG, "requested alerts download at time: "+timeRunning); + } + }); + serviceAlertsViewModel.launchPeriodDownload(); + } private ActionBarDrawerToggle setupDrawerToggle(Toolbar toolbar) { // NOTE: Make sure you pass in a valid toolbar reference. ActionBarDrawToggle() does not require it diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java b/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java --- a/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java @@ -51,7 +51,7 @@ } - public static Notification makeMatoDownloadNotification(Context context,String title){ + public static Notification makeDBUpdateLowPriorityNotification(Context context, String title){ return new NotificationCompat.Builder(context, Notifications.DB_UPDATE_CHANNELS_ID) //.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), Constants.PENDING_INTENT_FLAG_IMMUTABLE)) .setSmallIcon(R.drawable.ic_bus_stilized_transparent) @@ -81,7 +81,7 @@ .build(); } public static Notification makeMatoDownloadNotification(Context context){ - return makeMatoDownloadNotification(context, context.getString(R.string.downloading_data_mato)); + return makeDBUpdateLowPriorityNotification(context, context.getString(R.string.downloading_data_mato)); } public static Notification makeMQTTServiceNotification(Context context){ diff --git a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtAlertsRequest.kt b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtAlertsRequest.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtAlertsRequest.kt @@ -0,0 +1,47 @@ +package it.reyboz.bustorino.backend.gtfs + +import com.android.volley.NetworkResponse +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.HttpHeaderParser +import com.google.transit.realtime.GtfsRealtime +import com.google.transit.realtime.GtfsRealtime.FeedEntity +import it.reyboz.bustorino.backend.Fetcher +import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest.RequestError + +class GtfsRtAlertsRequest( + errorListener: Response.ErrorListener, + val listener: Response.Listener>) : + Request>(Method.GET, GtfsUtils.GTFSRT_URL_ALERTS, errorListener) { + override fun parseNetworkResponse(response: NetworkResponse?): Response> { + if (response == null){ + return Response.error(VolleyError("Response null")) + } + if (response.statusCode == 404){ + return Response.error(VolleyError("404")) + } + else if (response.statusCode != 200){ + return Response.error(VolleyError("200")) + } + + val gtfsreq = GtfsRealtime.FeedMessage.parseFrom(response.data) + + val alerts = ArrayList() + if(gtfsreq.hasHeader() && gtfsreq.entityCount>0){ + for (i in 0 until gtfsreq.entityCount) { + val entity = gtfsreq.getEntity(i) + + if (entity.hasAlert()){ + alerts.add(entity) + } + } + } + return Response.success(alerts, HttpHeaderParser.parseCacheHeaders(response)) + } + + override fun deliverResponse(p0: ArrayList) { + listener.onResponse(p0) + } + +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtPositionsRequest.kt b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtPositionsRequest.kt --- a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtPositionsRequest.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsRtPositionsRequest.kt @@ -29,7 +29,7 @@ class GtfsRtPositionsRequest( errorListener: ErrorListener, val listener: RequestListener) : - Request>(Method.GET, URL_POSITION, errorListener) { + Request>(Method.GET, GtfsUtils.GTFSRT_URL_POSITION, errorListener) { override fun parseNetworkResponse(response: NetworkResponse?): Response> { if (response == null){ @@ -67,10 +67,6 @@ } companion object{ - const val URL_POSITION = "http://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx" - - const val URL_TRIP_UPDATES ="http://percorsieorari.gtt.to.it/das_gtfsrt/trip_update.aspx" - const val URL_ALERTS = "http://percorsieorari.gtt.to.it/das_gtfsrt/alerts.aspx" interface RequestListener{ fun onResponse(response: ArrayList?) diff --git a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java --- a/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/gtfs/GtfsUtils.java @@ -23,6 +23,11 @@ abstract public class GtfsUtils { + public static final String GTFSRT_URL_POSITION = "http://percorsieorari.gtt.to.it/das_gtfsrt/vehicle_position.aspx"; + + public static final String GTFSRT_URL_TRIP_UPDATES ="http://percorsieorari.gtt.to.it/das_gtfsrt/trip_update.aspx"; + public static final String GTFSRT_URL_ALERTS = "http://percorsieorari.gtt.to.it/das_gtfsrt/alerts.aspx"; + public static String stripGtfsPrefix(String routeID){ String[] explo = routeID.split(":"); //default is diff --git a/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt b/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt @@ -0,0 +1,119 @@ +package it.reyboz.bustorino.data + +import android.app.NotificationManager +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkerParameters +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.RequestFuture +import com.google.transit.realtime.GtfsRealtime +import it.reyboz.bustorino.R +import it.reyboz.bustorino.backend.NetworkVolleyManager +import it.reyboz.bustorino.backend.Notifications +import it.reyboz.bustorino.backend.gtfs.GtfsRtAlertsRequest +import it.reyboz.bustorino.data.GtfsMaintenanceWorker.Companion.OPERATION_TYPE +import it.reyboz.bustorino.data.gtfs.GtfsAlertsActivePeriods +import it.reyboz.bustorino.data.gtfs.GtfsAlertsTranslation +import it.reyboz.bustorino.data.gtfs.GtfsAlertEntity +import it.reyboz.bustorino.data.gtfs.GtfsAlertInformedEntity +import it.reyboz.bustorino.data.gtfs.GtfsAlertsDBConverter +import it.reyboz.bustorino.data.gtfs.GtfsDatabase +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +class GtfsAlertDBDownloadWorker(appContext: Context, workerParams: WorkerParameters): + CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val volleyManager = NetworkVolleyManager.getInstance(applicationContext) + val gtfsDatabase = GtfsDatabase.getGtfsDatabase(applicationContext) + //use future to wait for request + + var attempts = 0 + var notOK = true + var resuList = ArrayList() + while (notOK && attempts < 5) { + Log.d(DEBUG_TAG, "Fetching alerts, trial $attempts") + val future = RequestFuture.newFuture>() + + val req = GtfsRtAlertsRequest(object : Response.ErrorListener { + override fun onErrorResponse(err: VolleyError) { + Log.e(DEBUG_TAG, "Error getting alerts: ${err.message}", err) + } + }, future) + + volleyManager.requestQueue.add(req) + try { + resuList = future.get(10, TimeUnit.SECONDS) + if (resuList.isNotEmpty()){ + Log.d(DEBUG_TAG, "Have no alerts, attempt $attempts") + notOK = false + } + } catch (e: InterruptedException) { + e.printStackTrace() + Log.e(DEBUG_TAG, e.message, e) + } catch (e: ExecutionException) { + e.printStackTrace() + Log.e(DEBUG_TAG, e.message, e) + } catch (e: TimeoutException) { + e.printStackTrace() + Log.e(DEBUG_TAG, e.message, e) + } + + attempts++ + } + if (notOK) { + return Result.failure() + } + + val timeReceived = System.currentTimeMillis() + val alertsToAdd = ArrayList() + val translToAdd = ArrayList() + val activePeriods = ArrayList() + val informedEntities = ArrayList() + for(e in resuList){ + val parsedRes = GtfsAlertsDBConverter.fromFeedEntity(e, timeReceived) + + alertsToAdd.add(parsedRes.alert) + translToAdd.addAll(parsedRes.translations) + activePeriods.addAll(parsedRes.activePeriods) + informedEntities.addAll(parsedRes.informedEntities) + } + Log.d(DEBUG_TAG, "alerts received: ${alertsToAdd.size}") + val dao =gtfsDatabase.alertsDao() + dao.insertMissingAlerts(alertsToAdd, translToAdd, activePeriods, informedEntities) + + dao.deleteOlderThan48h(timeReceived) + return Result.success() + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + + val context = applicationContext + Notifications.createDBNotificationChannelIfNeeded(context) + + return ForegroundInfo(NOTIFICATION_ID, + Notifications.makeDBUpdateLowPriorityNotification(context, context.getString(R.string.downloading_alerts_message))) + } + + + companion object{ + private const val NOTIFICATION_ID = 271899102 + private const val DEBUG_TAG = "BusTO-GTFSRTAlertsDown" + + fun makeOneTimeRequest(tag: String): OneTimeWorkRequest { + //val data = Data.Builder().putString(OPERATION_TYPE, type).build() + return OneTimeWorkRequest.Builder(GtfsAlertDBDownloadWorker::class.java) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag(tag) + .build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/data/GtfsRepository.kt b/app/src/main/java/it/reyboz/bustorino/data/GtfsRepository.kt --- a/app/src/main/java/it/reyboz/bustorino/data/GtfsRepository.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/GtfsRepository.kt @@ -6,10 +6,16 @@ import it.reyboz.bustorino.data.gtfs.* class GtfsRepository( - val gtfsDao: GtfsDBDao + context: Context ) { - constructor(context: Context) : this(GtfsDatabase.getGtfsDatabase(context).gtfsDao()) + val gtfsDao: GtfsDBDao + val alertsDao: AlertsDao + init{ + val gtfsDB = GtfsDatabase.getGtfsDatabase(context) + gtfsDao = gtfsDB.gtfsDao() + alertsDao = gtfsDB.alertsDao() + } fun getLinesLiveDataForFeed(feed: String): LiveData>{ //return withContext(Dispatchers.IO){ return gtfsDao.getRoutesForFeed(feed) @@ -39,4 +45,8 @@ fun getRouteFromGtfsId(gtfsId: String): LiveData{ return gtfsDao.getRouteByGtfsID(gtfsId) } + + fun getAlertsByRouteID(routeID: String): LiveData>{ + return alertsDao.getAlertsForRoute(routeID) + } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/Converters.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/Converters.kt --- a/app/src/main/java/it/reyboz/bustorino/data/gtfs/Converters.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/Converters.kt @@ -18,6 +18,8 @@ package it.reyboz.bustorino.data.gtfs import androidx.room.TypeConverter +import com.google.transit.realtime.GtfsRealtime.Alert.Cause +import com.google.transit.realtime.GtfsRealtime.Alert.Effect import java.text.SimpleDateFormat import java.util.* @@ -29,7 +31,6 @@ */ class Converters { - @TypeConverter fun fromString(value: String?): Date? { return dateFromFmtString(value) @@ -48,6 +49,25 @@ fun fromInt(value: Int?): GtfsServiceDate.ExceptionType? { return value?.let { GtfsServiceDate.ExceptionType.getByValue(it) } } + // FOR GTFS REALTIME ENUMS + @TypeConverter + fun fromCause(cause: Cause): Int { + return cause.number + } + + @TypeConverter + fun toCause(value: Int): Cause { + return Cause.forNumber(value) ?: Cause.UNKNOWN_CAUSE + } + @TypeConverter + fun fromEffect(effect: Effect): Int { + return effect.number + } + + @TypeConverter + fun toEffect(value: Int): Effect { + return Effect.forNumber(value) ?: Effect.UNKNOWN_EFFECT + } companion object{ const val DATE_FMT_STRING = "yyyyMMdd" diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDBConverter.kt @@ -0,0 +1,135 @@ +package it.reyboz.bustorino.data.gtfs + +import com.google.transit.realtime.GtfsRealtime + +/** + * Risultato del mapping di un singolo FeedEntity: + * tutte le righe pronte per essere passate a [AlertDao.upsertAlert]. + */ +data class MappedAlert( + val alert: GtfsAlertEntity, + val translations: List, + val activePeriods: List, + val informedEntities: List +) + +public object GtfsAlertsDBConverter { + + /** + * Converte un FeedEntity GTFS-RT (che contiene un Alert) nelle entity Room. + * + * @param entity il FeedEntity dal feed. Deve avere `hasAlert() == true`. + * @param fetchedAtMillis epoch millis del momento di ricezione/salvataggio. + * @return null se il FeedEntity non contiene un alert (es. è un TripUpdate). + */ + fun fromFeedEntity( + entity: GtfsRealtime.FeedEntity, + fetchedAtMillis: Long + ): MappedAlert { + if (!entity.hasAlert()) throw IllegalArgumentException("Alert entity can't be null") + + val al = entity.alert + val alertId = entity.id + + val alert = GtfsAlertEntity( + id = alertId, + cause = al.cause, + effect = al.effect, + fetchedAt = fetchedAtMillis, + userSeen = false + ) + + val translations = buildList { + // Header + if (al.hasHeaderText()) { + al.headerText.translationList.forEach { t -> + add( + GtfsAlertsTranslation( + alertId = alertId, + field = GtfsAlertsTranslation.FIELD_HEADER, + language = if (t.hasLanguage()) t.language else null, + text = t.text + ) + ) + } + } + // Description + if (al.hasDescriptionText()) { + al.descriptionText.translationList.forEach { t -> + add( + GtfsAlertsTranslation( + alertId = alertId, + field = GtfsAlertsTranslation.FIELD_DESCRIPTION, + language = if (t.hasLanguage()) t.language else null, + text = t.text + ) + ) + } + } + // URL (anche lui TranslatedString in GTFS-RT) + if (al.hasUrl()) { + al.url.translationList.forEach { t -> + add( + GtfsAlertsTranslation( + alertId = alertId, + field = GtfsAlertsTranslation.FIELD_URL, + language = if (t.hasLanguage()) t.language else null, + text = t.text + ) + ) + } + } + } + + val activePeriods = al.activePeriodList.map { tr -> + GtfsAlertsActivePeriods( + alertId = alertId, + start = if (tr.hasStart()) tr.start else null, + end = if (tr.hasEnd()) tr.end else null + ) + } + + val informedEntities = al.informedEntityList.map { e -> + + + val (tripId, tripRouteId, directionId) = if (e.hasTrip()) { + val td = e.trip + Triple( + if (td.hasTripId()) "gtt:${td.tripId}" else null, + if (td.hasRouteId()) "gtt:${td.routeId}" else null, + if (td.hasDirectionId()) td.directionId else null + ) + } else { + Triple(null, null, null) + } + + GtfsAlertInformedEntity( + alertId = alertId, + //agencyId = if (e.hasAgencyId()) e.agencyId else null, + routeId = if (e.hasRouteId()) "gtt:${e.routeId}" else null, + routeType = if (e.hasRouteType()) e.routeType else null, + stopId = if (e.hasStopId()) e.stopId else null, + tripId = tripId, + tripRouteId = tripRouteId, + directionId = directionId + ) + } + + return MappedAlert(alert, translations, activePeriods, informedEntities) + } + + /** + * Comodità: prende un intero FeedMessage e mappa solo i FeedEntity che sono alert, + * ignorando TripUpdate e VehiclePosition. + */ + fun fromFeedMessage( + feed: GtfsRealtime.FeedMessage, + fetchedAtMillis: Long = System.currentTimeMillis() + ): List { + return feed.entityList.mapNotNull { fe -> + // Salta gli entity marcati come deleted + if (fe.isDeleted || !fe.hasAlert()) null + else fromFeedEntity(fe, fetchedAtMillis) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDao.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDao.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsAlertsDao.kt @@ -0,0 +1,146 @@ +package it.reyboz.bustorino.data.gtfs + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction + +@Dao +interface AlertsDao { + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAlert(alert: GtfsAlertEntity) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAlerts(alerts: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertTranslations(items: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertActivePeriods(items: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertInformedEntities(items: List) + + @Query("DELETE FROM gtfsrt_alert_translations WHERE alertId = :id") + suspend fun deleteTranslationsFor(id: String) + + @Query("DELETE FROM alerts_active_periods WHERE alertId = :id") + suspend fun deleteActivePeriodsFor(id: String) + + @Query("DELETE FROM alerts_informed_entities WHERE alertId = :id") + suspend fun deleteInformedEntitiesFor(id: String) + + /** + * Inserisce o aggiorna un alert e tutti i suoi figli atomicamente. + * + * Nota: se l'alert esiste già, ne preserviamo il valore di `seen` esistente + * (non vogliamo che un re-fetch del feed reimposti a false un alert già letto). + * Il chiamante può forzare un valore passandolo dentro `alert.seen`; in quel + * caso si usa quello. + */ + @Transaction + suspend fun insertMissingAlerts( + alerts: List, + translations: List, + periods: List, + entities: List, + preserveSeen: Boolean = true + ) { + /* + *** CONSIDER THIS if we ever need to replace the data instead of ignoring *** + val toInsert = if (preserveSeen) { + val existingSeen = isUserSeen(alert.id) + if (existingSeen != null) alert.copy(userSeen = existingSeen) else alert + } else { + alert + } + insertAlert(toInsert) + + */ + + + // Pulizia esplicita dei figli prima di reinserirli. + // Le CASCADE coprirebbero il caso di REPLACE su PK, ma essere espliciti + // evita sorprese e funziona anche se un giorno cambiamo strategia. + //deleteTranslationsFor(alert.id) + //deleteActivePeriodsFor(alert.id) + //deleteInformedEntitiesFor(alert.id) + if(alerts.isNotEmpty()) insertAlerts(alerts) + if (translations.isNotEmpty()) insertTranslations(translations) + if (periods.isNotEmpty()) insertActivePeriods(periods) + if (entities.isNotEmpty()) insertInformedEntities(entities) + } + + // ---------- "Seen" flag ---------- + + @Query("SELECT userSeen FROM gtfsrt_alerts WHERE id = :id") + suspend fun isUserSeen(id: String): Boolean? + + @Query("UPDATE gtfsrt_alerts SET userSeen = :seen WHERE id = :id") + suspend fun setSeen(id: String, seen: Boolean) + + @Query("UPDATE gtfsrt_alerts SET userSeen = 1") + suspend fun markAllSeen() + + //@Query("SELECT COUNT(*) FROM gtfsrt_alerts WHERE userSeen = 0") + //suspend fun countUnseen(): Int + + // ---------- Read ---------- + + @Transaction + @Query("SELECT * FROM gtfsrt_alerts ORDER BY fetchedAt DESC") + fun getAllAlerts(): LiveData> + + @Transaction + @Query("SELECT * FROM gtfsrt_alerts WHERE userSeen = 0 ORDER BY fetchedAt DESC") + suspend fun getUnseenAlerts(): List + + @Transaction + @Query("SELECT * FROM gtfsrt_alerts WHERE id = :id") + suspend fun getAlert(id: String): AlertWithDetails? + + @Transaction + @Query(""" + SELECT a.* FROM gtfsrt_alerts a + INNER JOIN alerts_informed_entities ie ON ie.alertId = a.id + WHERE ie.stopId = :stopId + ORDER BY a.fetchedAt DESC + """) + fun getAlertsForStop(stopId: String): LiveData> + + @Transaction + @Query(""" + SELECT al.* FROM gtfsrt_alerts al + INNER JOIN alerts_informed_entities ie ON ie.alertId = al.id + WHERE ie.routeId = :routeId OR ie.tripRouteId = :routeId + ORDER BY al.fetchedAt DESC + """) + fun getAlertsForRoute(routeId: String): LiveData> + + // ---------- Delete ---------- + + @Query("DELETE FROM gtfsrt_alerts WHERE id = :id") + suspend fun deleteAlert(id: String) + + @Query("DELETE FROM gtfsrt_alerts") + suspend fun deleteAll() + + /** + * Cancella tutti gli alert ricevuti più di 48 ore fa. + * Le CASCADE sulle FK puliscono automaticamente translations, + * active_periods e informed_entities. + * + * @param now epoch millis "adesso" (default: System.currentTimeMillis()). + * Esposto come parametro per facilitare i test. + * @return numero di righe cancellate. + */ + @Query("DELETE FROM gtfsrt_alerts WHERE fetchedAt < :cutoff") + suspend fun deleteOlderThan(cutoff: Long): Int + + suspend fun deleteOlderThan48h(now: Long = System.currentTimeMillis()): Int { + val cutoff = now - 48L * 60L * 60L * 1000L + return deleteOlderThan(cutoff) + } +} diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsDatabase.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsDatabase.kt --- a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsDatabase.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsDatabase.kt @@ -34,11 +34,17 @@ GtfsTrip::class, GtfsShape::class, MatoPattern::class, - PatternStop::class + PatternStop::class, + //entities for GTFS Realtime Alerts + GtfsAlertEntity::class, + GtfsAlertsTranslation::class, + GtfsAlertsActivePeriods::class, + GtfsAlertInformedEntity::class ], version = GtfsDatabase.VERSION, autoMigrations = [ - AutoMigration(from=2,to=3) + AutoMigration(from=2,to=3), + AutoMigration(from=3,to=4) ], exportSchema = true ) @@ -47,6 +53,8 @@ abstract fun gtfsDao() : GtfsDBDao + abstract fun alertsDao(): AlertsDao + companion object{ @Volatile @@ -66,7 +74,7 @@ } } - const val VERSION = 3 + const val VERSION = 4 const val FOREIGNKEY_ONDELETE = ForeignKey.CASCADE val MIGRATION_1_2 = Migration(1,2) { diff --git a/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRtAlerts.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRtAlerts.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRtAlerts.kt @@ -0,0 +1,207 @@ +package it.reyboz.bustorino.data.gtfs + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.google.transit.realtime.GtfsRealtime.Alert.Cause +import com.google.transit.realtime.GtfsRealtime.Alert.Effect +import java.nio.Buffer +import java.security.MessageDigest + + +@Entity(tableName = "gtfsrt_alerts") +data class GtfsAlertEntity( + /** FeedEntity.id dal feed GTFS-RT, unico nel FeedMessage. */ + @PrimaryKey val id: String, + + /** Alert.cause.name, es. "TECHNICAL_PROBLEM", "STRIKE", ... */ + val cause: Cause, + + /** Alert.effect.name, es. "NO_SERVICE", "DETOUR", ... */ + val effect: Effect, + + /** Timestamp (epoch millis) di quando questo alert è stato ricevuto/salvato. */ + val fetchedAt: Long, + + /** True se l'utente ha già visto/letto questo alert. Default false. */ + val userSeen: Boolean = false +) +/** + * Traduzioni per i campi testuali dell'alert. + * `field` discrimina tra HEADER, DESCRIPTION e URL (tutti TranslatedString in GTFS-RT). + */ +@Entity( + tableName = "gtfsrt_alert_translations", + foreignKeys = [ + ForeignKey( + entity = GtfsAlertEntity::class, + parentColumns = ["id"], + childColumns = ["alertId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("alertId")] +) +data class GtfsAlertsTranslation( + @PrimaryKey val hash: String, + val alertId: String, + + /** "HEADER" | "DESCRIPTION" | "URL" */ + val field: String, + + /** BCP-47, può mancare nel feed (Translation.language è optional). */ + val language: String?, + + /** Translation.text è required nel .proto, quindi non-null qui. */ + val text: String +) { + constructor(alertId: String, field: String, language: String?, text: String) : this( + calcHash(alertId, field, language, text), + alertId, + field, + language, + text + ) + companion object { + const val FIELD_HEADER = "HEADER" + const val FIELD_DESCRIPTION = "DESCRIPTION" + const val FIELD_URL = "URL" + + fun calcHash(alertId: String, field: String, language: String?, text: String): String { + val md = MessageDigest.getInstance("MD5") + val coS = "$alertId|$field|$language|$text" + return md.digest(coS.toByteArray()).toHexString() + } + } + +} + +/** + * Un Alert può avere più TimeRange. Sia `start` che `end` sono optional nel.proto: + * - start mancante = "da sempre" + * - end mancante = "fino a tempo indeterminato" + */ +@Entity( + tableName = "alerts_active_periods", + foreignKeys = [ + ForeignKey( + entity = GtfsAlertEntity::class, + parentColumns = ["id"], + childColumns = ["alertId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("alertId")], +) +data class GtfsAlertsActivePeriods( + @PrimaryKey val hash: String, + val alertId: String, + + /** Epoch seconds (POSIX time, come da spec GTFS-RT). Null se non specificato. */ + val start: Long?, + val end: Long? +){ + constructor(alertId: String, start: Long?, end: Long?) : this( + calcHash(alertId, start, end), + alertId, start, end + ) + companion object{ + fun calcHash(alertId: String, start: Long?, end: Long?): String { + val input = "${alertId}|${start ?: ""}|${end ?: ""}" + val md = MessageDigest.getInstance("MD5") + return md.digest(input.toByteArray()).toHexString() + } + } +} +/** + * Un EntitySelector dal feed. Tutti i campi sono optional nel .proto: + * almeno uno deve essere valorizzato, ma quale dipende dal feed. + */ +@Entity( + tableName = "alerts_informed_entities", + foreignKeys = [ + ForeignKey( + entity = GtfsAlertEntity::class, + parentColumns = ["id"], + childColumns = ["alertId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index("alertId"), + Index("routeId"), + Index("stopId"), + Index("tripId") + ] +) +data class GtfsAlertInformedEntity( + @PrimaryKey val internalId: String, + val alertId: String, + + val routeId: String?, + /** route_type GTFS (0=tram, 1=metro, 2=rail, 3=bus, ...). */ + val routeType: Int?, + val stopId: String?, + + /** Campi dal TripDescriptor annidato, se presente. */ + val tripId: String?, + val tripRouteId: String?, + val directionId: Int? +){ + constructor( + alertId: String, routeId: String?, routeType: Int?, stopId: String?, tripId: String?, tripRouteId: String?, directionId: Int? + ): this( + calcHash(alertId, routeId, routeType, stopId, tripId, tripRouteId, directionId), + alertId, + routeId, + routeType, + stopId, + tripId, + tripRouteId, + directionId + ) + companion object{ + fun calcHash(alertId: String,routeId: String?, routeType: Int?, stopId: String?, tripId: String?, tripRouteId: String?, directionId: Int?): String { + val input = "${alertId}|${routeId ?: ""}|${routeType ?: ""}|${stopId ?: ""}|${tripId ?: ""}|${tripRouteId ?: ""}|${directionId ?: ""}" + val md = MessageDigest.getInstance("MD5") + return md.digest(input.toByteArray()).toHexString() + } + } +} + +/** + * POJO di lettura: un alert con tutti i suoi figli. + * Usato dai @Query @Transaction nel DAO. + */ +data class AlertWithDetails( + @Embedded val alert: GtfsAlertEntity, + + @Relation(parentColumn = "id", entityColumn = "alertId") + val translations: List, + + @Relation(parentColumn = "id", entityColumn = "alertId") + val activePeriods: List, + + @Relation(parentColumn = "id", entityColumn = "alertId") + val informedEntities: List +) { + fun longPrint(): String { + val sb = StringBuilder() + sb.append("======== ALERT ${alert.id} ======= \n") + for (t in translations){ + sb.append(t.field).append("\n") + sb.append(t.language).append(" : ").append(t.text).append("\n") + } + val ies = informedEntities + sb.append("Valid for: ") + for (i in ies){ + sb.append("Stop ${i.stopId}; Route ${i.routeId}; TripID ${i.tripId}; Trip Route ${i.tripRouteId}\n") + } + sb.append("\n") + return sb.toString() + } +} + diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt @@ -0,0 +1,67 @@ +package it.reyboz.bustorino.fragments + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.RecyclerView +import it.reyboz.bustorino.R +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel +import kotlin.getValue + + +class AlertsDialogFragment : DialogFragment() { + private var gtfsLineShow :String? = null + + private lateinit var titleTextView: TextView + private lateinit var recyclerView: RecyclerView + private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { args-> + gtfsLineShow = args.getString(GTFS_LINE_ARG) + } + + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val root = inflater.inflate(R.layout.fragment_dialog_alerts_line, container, false) + + titleTextView = root.findViewById(R.id.titleTextView) + recyclerView = root.findViewById(R.id.alertsRecyclerView) + alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ alerts -> + + } + return root + } + + companion object { + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @param gtfsLine Line To show. + * @return A new instance of fragment LineAlertsDialogFragment. + */ + @JvmStatic + fun newInstance(gtfsLine: String?) = + AlertsDialogFragment().apply { + arguments = Bundle().apply { + putString(GTFS_LINE_ARG, gtfsLine) + } + } + private const val GTFS_LINE_ARG = "gtfsLine" + + private const val DEBUG_TAG = "BusTO-AlertsDialog" + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt @@ -0,0 +1,165 @@ +package it.reyboz.bustorino.fragments + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import com.google.transit.realtime.GtfsRealtime +import it.reyboz.bustorino.R +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + + +/** + * A simple [Fragment] subclass. + * Use the [AlertsFragment.newInstance] factory method to + * create an instance of this fragment. + */ +class AlertsFragment : ScreenBaseFragment() { + + private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() + + private lateinit var textView: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + //param1 = it.getString(ARG_PARAM1) + //param2 = it.getString(ARG_PARAM2) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val root = inflater.inflate(R.layout.fragment_alerts, container, false) + textView = root.findViewById(R.id.simpleTextView) + + + alertsViewModel.allAlertsLiveData.observe(viewLifecycleOwner, { alerts -> + val sb = StringBuilder() + for (x in alerts) { + val al = x.alert + sb.append("======== ALERT ${al.id} ======= \n") + for (t in x.translations){ + sb.append(t.field).append("\n") + sb.append(t.language).append(" : ").append(t.text).append("\n") + } + val ies = x.informedEntities + sb.append("Valid for: ") + for (i in ies){ + sb.append("Stop ${i.stopId}; Route ${i.routeId}; TripID ${i.tripId}; Trip Route ${i.tripRouteId}\n") + } + sb.append("\n") + } + + textView.text = sb.toString() + }) + + + alertsViewModel.setStopFilter("472") + /*alertsViewModel.alertsForStop.observe(viewLifecycleOwner){ + Log.d(DEBUG_TAG, "Got ${it.size} alerts") + it?.let { + showAlerts(it) + } + } + + */ + /* + alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner) { map -> + Log.d(DEBUG_TAG, "Alerts for routes: ${map.keys}") + val keys = map.keys + if(keys.isNotEmpty()){ + val sb = StringBuilder() + for (key in keys.sorted()) { + sb.append(" ======== Route: $key =======").append("\n") + sb.append(makeAlertListText(map[key]!!)).append("\n") + Log.d(DEBUG_TAG, "Route: $key len: ${map[key]!!.size}") + } + + textView.text = sb.toString() + } + + } + + */ + return root + } + + override fun getBaseViewForSnackBar(): View? { + TODO("Not yet implemented") + } + + private fun makeAlertListText(alerts: List) : String{ + val sb = StringBuilder() + for (al in alerts) { + sb.append("=========== Alert ===========\n") + sb.append("Title:\n") + for (t in al.headerText.translationList) { + sb.append(t.language).append(": ").append(t.text).append("\n") + } + sb.append("Description:\n") + val transl = al.descriptionText.translationList + for (t in transl) { + sb.append(t.language).append(": ").append(t.text).append("\n") + } + val infE = al.informedEntityList + sb.append("--- Active periods count: ${al.activePeriodCount}\n") + val timeActive = al.getActivePeriod(0) + sb.append("Start: ").append(getTimeStampToString(timeActive.start)).append(" ") + sb.append("End: ").append(getTimeStampToString(timeActive.end)).append("\n") + sb.append("--- Cause:\n") + sb.append(al.cause.name).append("\n") + sb.append("--- Informed entities:\n") + for (e in infE) { + if(e.hasTrip()){ + sb.append("Trip: ${e.trip.tripId} for route ${e.trip.routeId}, ") + } else{ + sb.append("No Trip, ") + } + sb.append("Stop: ${e.stopId}, Route: ${e.routeId}\n") + } + sb.append("\n") + } + return sb.toString() + } + + companion object { + /** + * Use this factory method to create a new instance of + * this fragment using the provided parameters. + * + * @return A new instance of fragment AlertsFragment. + */ + @JvmStatic + fun newInstance() = + AlertsFragment().apply { + arguments = Bundle().apply { + //putString(ARG_PARAM1, param1) + //putString(ARG_PARAM2, param2) + } + } + + fun getTimeStampToString(timestamp: Long): String? { + val date = Date(timestamp*1000) + + val sdf= SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("Europe/Rome") + + return sdf.format(date) + } + + private const val DEBUG_TAG = "BusTO-AlertsFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -34,6 +34,7 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager @@ -55,6 +56,7 @@ import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.MapStateViewModel +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel import kotlinx.coroutines.Runnable import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory @@ -78,7 +80,7 @@ class LinesDetailFragment() : GeneralMapLibreFragment() { - private var lineID = "" + private var lineID = "" // the GTFS line ID (e.g. "gtt:10U") private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null @@ -97,9 +99,11 @@ private var patternShown: MatoPatternWithStops? = null private val viewModel: LinesViewModel by viewModels() + private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() //private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton + private lateinit var lineInfoButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null @@ -242,7 +246,7 @@ switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) busPositionsIconButton = rootView.findViewById(R.id.busPositionsImageButton) - + lineInfoButton = rootView.findViewById(R.id.lineInfoWarningButton) favoritesButton = rootView.findViewById(R.id.favoritesButton) stopsRecyclerView = rootView.findViewById(R.id.patternStopsRecyclerView) descripTextView = rootView.findViewById(R.id.lineDescripTextView) @@ -369,6 +373,20 @@ descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } + // enable info button if there are alerts on the line + alertsViewModel.setGtfsLineFilter(lineID) + alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ list -> + Log.d(DEBUG_TAG, "alerts for line $lineID: ${list.size}") + + if(list.isNotEmpty()){ + lineInfoButton.visibility = View.VISIBLE + Log.d(DEBUG_TAG, "First alert is:\n ${list[0].longPrint()}") + } else + lineInfoButton.visibility = View.GONE + } + lineInfoButton.setOnClickListener { + //TODO + } /* */ diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt @@ -63,8 +63,7 @@ } init { - val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() - gtfsRepo = GtfsRepository(gtfsDao) + gtfsRepo = GtfsRepository(application) routesLiveData = gtfsRepo.getAllRoutes() filteredLinesLiveData.addSource(routesLiveData){ diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesViewModel.kt @@ -38,8 +38,7 @@ stopsForPatternLiveData.postValue(stopsForPatternLiveData.value) } init { - val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() - gtfsRepo = GtfsRepository(gtfsDao) + gtfsRepo = GtfsRepository(application) oldRepo = OldDataRepository(executor, NextGenDB.getInstance(application)) diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt new file mode 100644 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/ServiceAlertsViewModel.kt @@ -0,0 +1,179 @@ +package it.reyboz.bustorino.viewmodels + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import androidx.room.concurrent.AtomicBoolean +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import com.android.volley.Response +import com.google.transit.realtime.GtfsRealtime +import com.google.transit.realtime.GtfsRealtime.Alert +import it.reyboz.bustorino.backend.NetworkVolleyManager +import it.reyboz.bustorino.backend.gtfs.GtfsRtAlertsRequest +import it.reyboz.bustorino.data.GtfsAlertDBDownloadWorker +import it.reyboz.bustorino.data.GtfsRepository +import it.reyboz.bustorino.data.gtfs.AlertWithDetails +import it.reyboz.bustorino.data.gtfs.GtfsDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class ServiceAlertsViewModel(app: Application) : AndroidViewModel(app) { + + private val gtfsRepo = GtfsRepository(app) + private val volleyManager = NetworkVolleyManager.getInstance(app) + + private val alertsDao = GtfsDatabase.getGtfsDatabase(app).alertsDao() + + private val workManager = WorkManager.getInstance(app) + + //val alertsLiveData = MutableLiveData>(ArrayList()) + + private val stopToFilter = MutableLiveData("") + private val routeToFilter = MutableLiveData("") + + val lastTimeRunningDownload = MutableLiveData(0L) + private val keepRunning = AtomicBoolean(false) + fun setRunningDownloadRequests(value: Boolean) { + keepRunning.set(value) + } + + val alertsByRouteLiveData = routeToFilter.switchMap { + gtfsRepo.getAlertsByRouteID(it) + } + + val alertsByStopLiveData = stopToFilter.switchMap { + gtfsRepo.alertsDao.getAlertsForStop(it) + } + + val allAlertsLiveData = gtfsRepo.alertsDao.getAllAlerts() + /* + private val volleyErrorListener = Response.ErrorListener { err -> + Log.e(DEBUG_TAG, "Error getting alerts: ${err.message}", err) + } + private var numTries = 0 + private val responseListener = Response.Listener> { + Log.d(DEBUG_TAG, "Received ${it.size} alerts") + if (it.isEmpty()) { + if(numTries<4){ + numTries++; + requestAlerts() + Log.d(DEBUG_TAG, "Alerts requested again: $numTries") + } + } + + alertsLiveData.postValue(it.map { it.alert }) + } + + private fun requestAlerts(){ + val req = GtfsRtAlertsRequest(volleyErrorListener, responseListener) + + volleyManager.requestQueue.add(req) + } + + */ + + fun setStopFilter(stopId: String) { + stopToFilter.value = stopId + } + fun setGtfsLineFilter(routeId: String) { + routeToFilter.value = routeId + } + + private fun downloadWorkIfTimePassed(){ + val currentTime = System.currentTimeMillis() + if (lastTimeRunningDownload.value == 0L || + currentTime > lastTimeRunningDownload.value!! + 5L*60*1000){ + //actually enqueue request + Log.d(DEBUG_TAG, "Launching request to download alerts") + val req = GtfsAlertDBDownloadWorker.makeOneTimeRequest("alertsrn") + workManager.enqueueUniqueWork("AlertsDownloadsRun", ExistingWorkPolicy.KEEP, req) + lastTimeRunningDownload.value = System.currentTimeMillis() + + viewModelScope.launch(Dispatchers.IO) { + delay((5L*60 +1).seconds) + if(keepRunning.get()) downloadWorkIfTimePassed() + } + } + + } + + fun launchPeriodDownload(){ + setRunningDownloadRequests(true) + downloadWorkIfTimePassed() + } + + + + private fun filterAlertsForStop(stopId: String, alerts: ArrayList) : ArrayList{ + + val filteredAlerts = ArrayList() + for (al in alerts) { + for (ie in al.informedEntityList) { + if (ie.stopId == stopId) { + filteredAlerts.add(al) + } + } + } + return filteredAlerts + } + + init{ + + /* + requestAlerts() + + alertsByRouteLiveData.addSource(alertsLiveData){ alerts -> + if(alerts.isEmpty()){ + return@addSource + } + val routeMap = HashMap>() + for (al in alerts){ + for( ie in al.informedEntityList){ + var routeID = "" + if(ie.routeId.isNotEmpty()){ + routeID = "gtt:${ie.routeId}" + } else if(ie.trip?.routeId?.isNotEmpty() == true){ + routeID = "gtt:${ie.trip?.routeId}" + } + if (routeID.isNotEmpty()) { + if (!routeMap.containsKey(routeID)) { + routeMap[routeID] = ArrayList() + } + + routeMap[routeID]!!.add(al) + } + } + } + + alertsByRouteLiveData.postValue(routeMap) + } + // Set transformations for stop + alertsForStop.addSource(stopToFilter){ stopId -> + alertsLiveData.value?.let{ + alertsForStop.postValue(filterAlertsForStop(stopId,it)) + } + } + + alertsForStop.addSource(alertsLiveData){ alerts -> + alertsForStop.postValue(filterAlertsForStop(stopToFilter.value!!,alerts)) + } + + + */ + } + + companion object{ + private const val DEBUG_TAG = "BusTO-GTFSRTAlerts" + + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/chat_bubble_warning_solid.xml b/app/src/main/res/drawable/chat_bubble_warning_solid.xml new file mode 100644 --- /dev/null +++ b/app/src/main/res/drawable/chat_bubble_warning_solid.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_alerts.xml b/app/src/main/res/layout/fragment_alerts.xml new file mode 100644 --- /dev/null +++ b/app/src/main/res/layout/fragment_alerts.xml @@ -0,0 +1,29 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_alerts_line.xml b/app/src/main/res/layout/fragment_dialog_alerts_line.xml new file mode 100644 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_alerts_line.xml @@ -0,0 +1,34 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_buspositions.xml b/app/src/main/res/layout/fragment_dialog_buspositions.xml --- a/app/src/main/res/layout/fragment_dialog_buspositions.xml +++ b/app/src/main/res/layout/fragment_dialog_buspositions.xml @@ -1,5 +1,6 @@ - + + + + Updates of the app database BusTO - live position service Live positions - Showing activity related to the live positions service + Showing activity related to the live positions service + MaTO live bus positions service is running Downloading trips from MaTO server @@ -258,6 +259,7 @@ Filter by name Launching database update Downloading data from MaTO server + Downloading realtime alerts data Capitalize directions @@ -374,7 +376,9 @@ GTFS RT Live positions source: Switch source - Clear bus positions when switching live positions source + Clear bus positions when switching live positions + source + Updated: %1$s