commit 6e383fd97f1f46b1230feb01a511dfce10d015a8 Author: Fabio Mazza Date: Thu Apr 30 16:05:23 2026 +0200 Download GTFS RT Alerts and show them in the lines screen Summary: Show the GTFS Alerts in a Line screen, opening a dialog in the process. Also, cache the Alerts on the device using Room, deleting them after 24hrs. Screenshots: {F13281756} {F13281755} Fix T1165 Test Plan: - Open lines, select 10 - Check that there is a "notification"-like icon to press (which should be) - Press the icon and see Reviewers: #libre_busto_hackers, valerio.bozzolan Reviewed By: #libre_busto_hackers, valerio.bozzolan Subscribers: valerio.bozzolan Project Tags: #libre_busto Maniphest Tasks: T1165 Differential Revision: https://gitpull.it/D234 diff --git a/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java b/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java index 304081f..cf0a5e5 100644 --- a/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityExperiments.java @@ -55,7 +55,7 @@ public class ActivityExperiments extends GeneralActivity implements CommonFragme //.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 index 7295c22..2f6aee4 100644 --- a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java @@ -40,6 +40,7 @@ import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import androidx.work.WorkInfo; @@ -55,6 +56,7 @@ import it.reyboz.bustorino.data.PreferencesHolder; 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 +73,7 @@ public class ActivityPrincipal extends GeneralActivity implements FragmentListen private boolean showingMainFragmentFromOther = false; private boolean onCreateComplete = false; + private ServiceAlertsViewModel serviceAlertsViewModel; private final OnBackPressedCallback callback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { @@ -84,6 +87,8 @@ public class ActivityPrincipal extends GeneralActivity implements FragmentListen 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 +106,6 @@ public class ActivityPrincipal extends GeneralActivity implements FragmentListen else Log.w(DEBUG_TAG, "NO ACTION BAR"); mToolbar.setOnMenuItemClickListener(new ToolbarItemClickListener(this)); - mDrawer = findViewById(R.id.drawer_layout); drawerToggle = setupDrawerToggle(mToolbar); @@ -300,13 +304,19 @@ public class ActivityPrincipal extends GeneralActivity implements FragmentListen - //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.launchAlertsPeriodCheck(); + } private ActionBarDrawerToggle setupDrawerToggle(Toolbar toolbar) { // NOTE: Make sure you pass in a valid toolbar reference. ActionBarDrawToggle() does not require it @@ -846,4 +856,16 @@ public class ActivityPrincipal extends GeneralActivity implements FragmentListen } } + @Override + protected void onPause() { + super.onPause(); + // stop updating the alerts + serviceAlertsViewModel.setRunningDownloadRequests(false); + } + + @Override + protected void onResume() { + super.onResume(); + serviceAlertsViewModel.launchAlertsPeriodCheck(); + } } diff --git a/app/src/main/java/it/reyboz/bustorino/adapters/AlertLineFullAdapter.kt b/app/src/main/java/it/reyboz/bustorino/adapters/AlertLineFullAdapter.kt new file mode 100644 index 0000000..efc0368 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/adapters/AlertLineFullAdapter.kt @@ -0,0 +1,56 @@ +package it.reyboz.bustorino.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import it.reyboz.bustorino.R +import it.reyboz.bustorino.data.gtfs.AlertWithDetails +import it.reyboz.bustorino.data.gtfs.GtfsAlertsTranslation + +class AlertLineFullAdapter(val alerts: List, + val locale: String + ) :RecyclerView.Adapter() { + + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ViewHolder { + + val v = LayoutInflater.from(parent.context).inflate(LAYOUT_ID, parent, false) + + return ViewHolder(v) + } + + override fun onBindViewHolder( + holder: ViewHolder, + position: Int + ) { + val al = alerts[position] + + var til = al.translations.filter { it.field == GtfsAlertsTranslation.FIELD_HEADER && it.language == locale } + var text = if(til.isEmpty()) "404" else til[0].text + holder.titleTextView.text = text + + til = al.translations.filter { it.field == GtfsAlertsTranslation.FIELD_DESCRIPTION && it.language == locale } + text = if(til.isEmpty()) "404" else til[0].text + holder.bodyTextView.text = text + + } + + override fun getItemCount(): Int { + return alerts.size + } + + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view){ + val titleTextView: TextView = view.findViewById(R.id.messageTitleTextView) + val bodyTextView: TextView = view.findViewById(R.id.messageBodyTextView) + + } + companion object{ + private val LAYOUT_ID = R.layout.entry_alert_line_adapter + } +} \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java b/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java index c1b1e4a..3afcd39 100644 --- 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 class Notifications { } - 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 @@ public class Notifications { .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 index 0000000..986cd38 --- /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 index 270521d..d239ee7 100644 --- 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 @@ import it.reyboz.bustorino.backend.Fetcher 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 @@ class GtfsRtPositionsRequest( } 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 index 4dbcb15..8ace7f2 100644 --- 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 @@ import it.reyboz.bustorino.backend.ServiceType; 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/backend/mato/MQTTMatoClient.kt b/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt index de6a912..826683c 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt +++ b/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt @@ -353,7 +353,7 @@ class MQTTMatoClient(){ } //Log.d(DEBUG_TAG, "We have update on line $lineId, vehicle $vehicleId") } catch (e: JSONException){ - Log.w(DEBUG_TAG,"Cannot decipher message on topic $topic, line $lineId, veh $vehicleId (bad JSON)") + Log.e(DEBUG_TAG,"Cannot decipher message on topic $topic, line $lineId, veh $vehicleId (bad JSON)",e) sendStatusToResponders(LivePositionsServiceStatus.ERROR_PARSING_RESPONSE) 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 index 0000000..32d7b15 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/GtfsAlertDBDownloadWorker.kt @@ -0,0 +1,120 @@ +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 + val dao =gtfsDatabase.alertsDao() + //clear old ones + dao.deleteOlderThanHours(24) + + 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}") + dao.insertMissingAlerts(alertsToAdd, translToAdd, activePeriods, informedEntities) + + 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 index e41b57b..527c4ff 100644 --- 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 androidx.lifecycle.MutableLiveData 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 @@ class GtfsRepository( 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/AlertsDao.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt new file mode 100644 index 0000000..5487ad4 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/AlertsDao.kt @@ -0,0 +1,181 @@ +package it.reyboz.bustorino.data.gtfs + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Delete +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 getAllAlertsLiveData(): LiveData> + + @Transaction + @Query("SELECT * FROM gtfsrt_alerts") + suspend fun getAllAlerts(): List + + @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) + + + @Delete + suspend fun deleteAlerts(alerts: List) + @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 + + //TODO use this to remove inactive alerts + suspend fun deleteInactiveAlerts() { + val alerts = getAllAlerts() + val alertsRemove = ArrayList() + val currentUnixTime = (System.currentTimeMillis()/1000).toInt() + for (a in alerts) { + var active = false + for(p in a.activePeriods){ + if(p.end==null || p.start==null) continue + if (p.start <= currentUnixTime && p.end>=currentUnixTime) { + active = true + break + } + } + if(!active) + alertsRemove.add(a.alert) + } + deleteAlerts(alertsRemove) + } + + suspend fun deleteOlderThan48h(now: Long = System.currentTimeMillis()): Int { + val cutoff = now - 48L * 60L * 60L * 1000L + return deleteOlderThan(cutoff) + } + + suspend fun deleteOlderThanHours(hours: Long, now : Long = System.currentTimeMillis()): Int { + val cutoff = now - hours *60L*60L*1000 + return deleteOlderThan(cutoff) + } +} 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 index 9c2d28c..529a6e4 100644 --- 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 @@ import java.util.* */ class Converters { - @TypeConverter fun fromString(value: String?): Date? { return dateFromFmtString(value) @@ -48,6 +49,25 @@ class Converters { 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 index 0000000..293e247 --- /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/GtfsDatabase.kt b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsDatabase.kt index d35c609..6404972 100644 --- 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 @@ import androidx.room.migration.Migration 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 class GtfsDatabase : RoomDatabase() { abstract fun gtfsDao() : GtfsDBDao + abstract fun alertsDao(): AlertsDao + companion object{ @Volatile @@ -66,7 +74,7 @@ abstract class GtfsDatabase : RoomDatabase() { } } - 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 index 0000000..6e86683 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/data/gtfs/GtfsRtAlerts.kt @@ -0,0 +1,233 @@ +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 it.reyboz.bustorino.backend.utils +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") + } + sb.append("-- Cause: ").append(alert.cause.name).append("\n") + sb.append("-- Active periods:\n") + + for(p in activePeriods){ + if(p.start==null || p.end==null){ + continue + } + sb.append("From: ").append(utils.unixTimestampToLocalTime(p.start)) + sb.append(" to: ").append(utils.unixTimestampToLocalTime(p.end)).append("\n") + } + val ies = informedEntities + sb.append("-- Valid for: \n") + 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() + } + + fun isActive(unixTimeStamp: Long): Boolean { + var active = false + for( ac in activePeriods){ + if(ac.start==null || ac.end == null) + continue + if (ac.start <= unixTimeStamp && ac.end >= unixTimeStamp) { + active = true + break + } + } + return active + } + + +} + 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 index 0000000..00a0801 --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsDialogFragment.kt @@ -0,0 +1,143 @@ +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.ImageButton +import android.widget.TextView +import android.widget.Toast +import androidx.cardview.widget.CardView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import it.reyboz.bustorino.R +import it.reyboz.bustorino.adapters.AlertLineFullAdapter +import it.reyboz.bustorino.backend.gtfs.GtfsUtils +import it.reyboz.bustorino.data.GtfsAlertDBDownloadWorker +import it.reyboz.bustorino.data.gtfs.AlertWithDetails +import it.reyboz.bustorino.data.gtfs.GtfsAlertsTranslation +import it.reyboz.bustorino.viewmodels.ServiceAlertsViewModel +import java.util.Locale +import kotlin.getValue +import kotlin.collections.HashMap + + +class AlertsDialogFragment(private val gtfsLineShow: String) : DialogFragment() { + + private lateinit var titleTextView: TextView + private lateinit var messageTextView: TextView + private lateinit var statusCardView: CardView + private lateinit var recyclerView: RecyclerView + private val alertsViewModel: ServiceAlertsViewModel by activityViewModels() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d(DEBUG_TAG, "created DialogFragment for line ${gtfsLineShow}") + } + + 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) + titleTextView.setText(getString(R.string.alert_line_fill,GtfsUtils.lineNameDisplayFromGtfsID(gtfsLineShow))) + recyclerView = root.findViewById(R.id.alertsRecyclerView) + recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + messageTextView = root.findViewById(R.id.alertMessageTextView) + statusCardView = root.findViewById(R.id.statusCard) + alertsViewModel.alertsByRouteLiveData.observe(viewLifecycleOwner){ alerts -> + showAlerts(alerts) + } + + val btnClose = root.findViewById(R.id.btnClose) + btnClose.setOnClickListener { + dismiss() + } + + val btnRefresh = root.findViewById(R.id.btnRefresh) + btnRefresh.setOnClickListener { + val name = "manualUpdateAlerts" + val req = GtfsAlertDBDownloadWorker.makeOneTimeRequest("manualUpdate$gtfsLineShow") + WorkManager.getInstance(requireContext()).enqueueUniqueWork(name, ExistingWorkPolicy.KEEP,req) + Toast.makeText(context, R.string.checking_alerts_update, Toast.LENGTH_SHORT).show() + } + return root + } + + override fun onStart() { + super.onStart() + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + + private fun showAlerts(alerts: List) { + val currentLang = Locale.getDefault().language + val ms = "language : $currentLang" + val langs_msg = HashMap() + for (a in alerts) { + for (tr in a.translations){ + if(tr.field == GtfsAlertsTranslation.FIELD_HEADER){ + tr.language?.let{ + if(langs_msg.containsKey(it)){ + langs_msg[it] = langs_msg[it]!! + 1 + } else{ + langs_msg[it] = 1 + } + } + //found the title, stop + break + } + } + } + Log.d(DEBUG_TAG, "Lang $currentLang, alerts: $langs_msg, of lang: ${langs_msg[currentLang]}") + val msgInLang = langs_msg[currentLang]?: 0 + val langShow = if (msgInLang > 0){ + currentLang + } else if("en" in langs_msg.keys){ + "en" + } else{ + "it" + } // if there are no messages with "it", then it's over + val count = langs_msg[langShow] ?: 0 + if (count == 0){ + messageTextView.text = "ERROR: NO ALERTS TO SHOW" + statusCardView.visibility = View.VISIBLE + } else if(msgInLang == 0){ + val msgShow = if(langShow == "en") getString(R.string.english) else getString(R.string.italian) + messageTextView.text = getString(R.string.no_alerts_in_your_language_fill, msgShow) + statusCardView.visibility = View.VISIBLE + } + // put them in the adapter + if(count>0){ + recyclerView.adapter = AlertLineFullAdapter(alerts, langShow) + } + } + + 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(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 index 0000000..d89341f --- /dev/null +++ b/app/src/main/java/it/reyboz/bustorino/fragments/AlertsFragment.kt @@ -0,0 +1,156 @@ +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.cardview.widget.CardView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.RecyclerView +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() + val unixTimestamp = (System.currentTimeMillis() / 1000) + for (x in alerts) { + sb.append(x.longPrint()) + sb.append("----- Alert active: ").append(x.isActive(unixTimestamp)).append("\n\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 index 6c0366b..8fa6f6d 100644 --- 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.activity.result.contract.ActivityResultContracts 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.middleware.LocationUtils 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 @@ import java.util.concurrent.atomic.AtomicBoolean 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 @@ class LinesDetailFragment() : GeneralMapLibreFragment() { 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 @@ class LinesDetailFragment() : GeneralMapLibreFragment() { 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 @@ class LinesDetailFragment() : GeneralMapLibreFragment() { 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 { + AlertsDialogFragment(lineID).show(parentFragmentManager, "Alerts-Line$lineID") + } /* */ diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LinesGridShowingViewModel.kt index 150dd9f..431416d 100644 --- 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 @@ class LinesGridShowingViewModel(application: Application) : AndroidViewModel(app } 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 index d73e400..43aa5fe 100644 --- 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 @@ class LinesViewModel(application: Application) : AndroidViewModel(application) { 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/LivePositionsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt index 22f8afe..239c92e 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt @@ -309,7 +309,7 @@ class LivePositionsViewModel(application: Application): AndroidViewModel(applica val numUpds = updates.entries.size Log.d( DEBUG_TI, - "Got $numUpds updates, current pattern is: ${pattern?.name}, directionID: ${pattern?.directionId}" + "Got $numUpds updates, using MQTT: ${useMQTTPositionsLiveData.value}, pattern ${pattern?.name}" ) // cannot understand where this is used //val patternsDirections = HashMap() @@ -326,7 +326,7 @@ class LivePositionsViewModel(application: Application): AndroidViewModel(applica if (dir == directionId) { //add the trip updsForTripId[tripId] = pair - Log.d(DEBUG_TI, "Add vehicle ${pair.first.vehicle}, route ${pair.first.routeID}") + //Log.d(DEBUG_TI, "Add vehicle ${pair.first.vehicle}, route ${pair.first.routeID}") } else { vehicleOnWrongDirection.add(vehicle) } @@ -479,6 +479,6 @@ class LivePositionsViewModel(application: Application): AndroidViewModel(applica private const val MAX_MINUTES_RETRY = 3 private const val MAX_TIME_RETRY = MAX_MINUTES_RETRY*60*1000 //3 minutes (in milliseconds) - public const val MAX_MINUTES_CLEAR_POSITIONS = 8 + public const val MAX_MINUTES_CLEAR_POSITIONS = 10 } } \ No newline at end of file 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 index 0000000..4812de7 --- /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.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.google.transit.realtime.GtfsRealtime.Alert +import it.reyboz.bustorino.backend.NetworkVolleyManager +import it.reyboz.bustorino.data.GtfsAlertDBDownloadWorker +import it.reyboz.bustorino.data.GtfsRepository +import it.reyboz.bustorino.data.gtfs.GtfsDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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) + private val waitingToRerun = AtomicBoolean(false) + fun setRunningDownloadRequests(value: Boolean) { + Log.d(DEBUG_TAG, "setRunningDownloadRequests: $value") + keepRunning.set(value) + } + + val alertsByRouteLiveData = routeToFilter.switchMap { + val unixTimestamp = (System.currentTimeMillis()/1000) + gtfsRepo.getAlertsByRouteID(it).map{ l -> l.filter { al->al.isActive(unixTimestamp) }} + } + + val alertsByStopLiveData = stopToFilter.switchMap { + gtfsRepo.alertsDao.getAlertsForStop(it) + } + + val allAlertsLiveData = gtfsRepo.alertsDao.getAllAlertsLiveData() + /* + 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() + waitingToRerun.set(false) + val diff = currentTime - lastTimeRunningDownload.value!! + Log.d(DEBUG_TAG, "diff : ${diff/1000} s") + val MINUTES_CHECK = 3 + if (lastTimeRunningDownload.value == 0L || + currentTime > lastTimeRunningDownload.value!! + MINUTES_CHECK*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.postValue(System.currentTimeMillis()) + } + viewModelScope.launch(Dispatchers.IO) { + waitingToRerun.set(true) + delay((61).seconds) + if(keepRunning.get()) downloadWorkIfTimePassed() + } + + } + + fun launchAlertsPeriodCheck(){ + setRunningDownloadRequests(true) + if(!waitingToRerun.get()) + 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 index 0000000..46ae436 --- /dev/null +++ b/app/src/main/res/drawable/chat_bubble_warning_solid.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/refresh_line.xml b/app/src/main/res/drawable/refresh_line.xml new file mode 100644 index 0000000..07bdc0b --- /dev/null +++ b/app/src/main/res/drawable/refresh_line.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/entry_alert_line_adapter.xml b/app/src/main/res/layout/entry_alert_line_adapter.xml new file mode 100644 index 0000000..6819e5d --- /dev/null +++ b/app/src/main/res/layout/entry_alert_line_adapter.xml @@ -0,0 +1,44 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_alerts.xml b/app/src/main/res/layout/fragment_alerts.xml new file mode 100644 index 0000000..d4dffd9 --- /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 index 0000000..80f5936 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_alerts_line.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + \ 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 index 80369bf..b100817 100644 --- a/app/src/main/res/layout/fragment_dialog_buspositions.xml +++ b/app/src/main/res/layout/fragment_dialog_buspositions.xml @@ -1,5 +1,6 @@ - + + + + Impostazioni per personalizzare l\'app come preferisci, e su Cambia fonte Rimuovi posizioni sulla mappa quando si cambia fonte delle posizioni in tempo reale Aggiornato: %1$s + + Nessun avviso nella tua lingua, mostrati in %1$s + Italiano + Inglese + Avvisi per la linea %1$s: + Controllo degli avvisi disponibili in corso + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index c76d40c..84920a7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -13,5 +13,6 @@ 28sp 43dp + 50dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 353e7b2..6b0972d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -230,7 +230,8 @@ 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 @@ -290,7 +292,6 @@ @string/nav_map_text @string/lines - Source of real time positions for buses and trams MaTO (updated more frequently, might have errors) GTFS RT (less frequently updated, more accurate) @@ -374,8 +375,14 @@ Look in the Settings to customize the app behaviour, and in the About 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 - + Alerts for line %1$s: + No alerts in your language, showing in %1$s + Italian + English + Checking new alerts now