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 @@ -1,104 +1,104 @@ /* BusTO - Data components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino; import android.os.Bundle; import android.util.Log; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.FragmentTransaction; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.fragments.*; import it.reyboz.bustorino.middleware.GeneralActivity; public class ActivityExperiments extends GeneralActivity implements CommonFragmentListener { final static String DEBUG_TAG = "ExperimentsActivity"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_container_fragment); ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(false); actionBar.setIcon(R.drawable.ic_launcher); } if (savedInstanceState==null) { getSupportFragmentManager().beginTransaction() .setReorderingAllowed(true) /* .add(R.id.fragment_container_view, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgs("gtt:4U")) */ //.add(R.id.fragment_container_view, LinesGridShowingFragment.class, null) //.add(R.id.fragment_container_view, IntroFragment.class, IntroFragment.makeArguments(0)) //.commit(); //.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(); } } @Override public void showFloatingActionButton(boolean yes) { Log.d(DEBUG_TAG, "Asked to show the action button"); } @Override public void readyGUIfor(FragmentKind fragmentType) { Log.d(DEBUG_TAG, "Asked to prepare the GUI for fragmentType "+fragmentType); } @Override public void requestArrivalsForStopID(String ID) { } @Override public void showMapCenteredOnStop(Stop stop) { } @Override public void openLineFromStop(String routeGtfsId, @Nullable String stopIDFrom){ readyGUIfor(FragmentKind.LINES); FragmentTransaction tr = getSupportFragmentManager().beginTransaction(); tr.replace(R.id.fragment_container_view, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgs(routeGtfsId, stopIDFrom)); tr.addToBackStack("LineonMap-"+routeGtfsId); tr.commit(); } @Override public void openLineFromVehicle(String routeGtfsId, @Nullable String optionalPatternId, @Nullable Bundle args) { readyGUIfor(FragmentKind.LINES); FragmentTransaction tr = getSupportFragmentManager().beginTransaction(); tr.replace(R.id.mainActContentFrame, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgsPattern(routeGtfsId, optionalPatternId, args)); tr.addToBackStack("Line-"+routeGtfsId); tr.commit(); } } \ No newline at end of file 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 @@ -1,849 +1,871 @@ /* BusTO - Arrival times for Turin public transport. Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino; import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.*; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.widget.Toolbar; import androidx.core.graphics.Insets; import androidx.core.view.*; 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; import com.google.android.material.navigation.NavigationView; import com.google.android.material.snackbar.Snackbar; import java.util.Arrays; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.data.DBUpdateCheckWorker; import it.reyboz.bustorino.data.DBUpdateWorker; 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; public class ActivityPrincipal extends GeneralActivity implements FragmentListenerMain { private DrawerLayout mDrawer; private NavigationView mNavView; private ActionBarDrawerToggle drawerToggle; private final static String DEBUG_TAG="BusTO Act Principal"; private final static String TAG_FAVORITES="favorites_frag"; private Snackbar snackbar; private boolean showingMainFragmentFromOther = false; private boolean onCreateComplete = false; + private ServiceAlertsViewModel serviceAlertsViewModel; private final OnBackPressedCallback callback = new OnBackPressedCallback(false) { @Override public void handleOnBackPressed() { activityCustomBackPressed(); } }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { 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); } */ //onBackPressed solution required from Android 16 callback.setEnabled(true); this.getOnBackPressedDispatcher().addCallback( callback); boolean showingArrivalsFromIntent = false; final Toolbar mToolbar = findViewById(R.id.default_toolbar); setSupportActionBar(mToolbar); if (getSupportActionBar()!=null) getSupportActionBar().setDisplayHomeAsUpEnabled(true); else Log.w(DEBUG_TAG, "NO ACTION BAR"); mToolbar.setOnMenuItemClickListener(new ToolbarItemClickListener(this)); - mDrawer = findViewById(R.id.drawer_layout); drawerToggle = setupDrawerToggle(mToolbar); // Setup toggle to display hamburger icon with nice animation drawerToggle.setDrawerIndicatorEnabled(true); drawerToggle.syncState(); mDrawer.addDrawerListener(drawerToggle); mDrawer.addDrawerListener(new DrawerLayout.DrawerListener() { @Override public void onDrawerSlide(@NonNull View drawerView, float slideOffset) { } @Override public void onDrawerOpened(@NonNull View drawerView) { hideKeyboard(); } @Override public void onDrawerClosed(@NonNull View drawerView) { } @Override public void onDrawerStateChanged(int newState) { } }); mNavView = findViewById(R.id.nvView); setupDrawerContent(mNavView); /*View header = mNavView.getHeaderView(0); */ //mNavView.getMenu().findItem(R.id.versionFooter). /// LEGACY CODE //---------------------------- START INTENT CHECK QUEUE ------------------------------------ // Intercept calls from URL intent boolean tryedFromIntent = false; String busStopID = null; Uri data = getIntent().getData(); if (data != null) { busStopID = getBusStopIDFromUri(data); Log.d(DEBUG_TAG, "Opening Intent: busStopID: "+busStopID); tryedFromIntent = true; } // Intercept calls from other activities if (!tryedFromIntent) { Bundle b = getIntent().getExtras(); if (b != null) { busStopID = b.getString("bus-stop-ID"); /* * I'm not very sure if you are coming from an Intent. * Some launchers work in strange ways. */ tryedFromIntent = busStopID != null; } } //---------------------------- END INTENT CHECK QUEUE -------------------------------------- if (busStopID == null) { // Show keyboard if can't start from intent // JUST DON'T // showKeyboard(); // You haven't obtained anything... from an intent? if (tryedFromIntent) { // This shows a luser warning Toast.makeText(getApplicationContext(), R.string.insert_bus_stop_number_error, Toast.LENGTH_SHORT).show(); } } else { // If you are here an intent has worked successfully //setBusStopSearchByIDEditText(busStopID); //Log.d(DEBUG_TAG, "Requesting arrivals for stop "+busStopID+" from intent"); requestArrivalsForStopID(busStopID); //this shows the fragment, too showingArrivalsFromIntent = true; } //database check // THIS CHECK IS DUPLICATED, TODO: REMOVE final boolean dataUpdateRequested = checkIfNeedSpecialUpgradeDB(); if(!dataUpdateRequested) // DatabaseUpdate.requestDBUpdateWithWork(this, false, false); DBUpdateCheckWorker.Companion.schedulePeriodicCheck(this,false); /* Watch for database update */ DBUpdateWorker.getWorkInfoLiveData(this) .observe(this, workInfoList -> { // If there are no matching work info, do nothing if (workInfoList == null || workInfoList.isEmpty()) { return; } Log.d(DEBUG_TAG, "WorkerInfo: "+workInfoList); boolean showProgress = false; for (WorkInfo workInfo : workInfoList) { if (workInfo.getState() == WorkInfo.State.RUNNING) { showProgress = true; break; } } if (showProgress) { createDefaultSnackbar(); } else { if(snackbar!=null) { snackbar.dismiss(); snackbar = null; } } }); // show the main fragment Fragment f = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); Log.d(DEBUG_TAG, "OnCreate the fragment is "+f); String vl = PreferenceManager.getDefaultSharedPreferences(this).getString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, ""); //if (vl.length() == 0 || vl.equals("arrivals")) { // showMainFragment(); Log.d(DEBUG_TAG, "The default screen to open is: "+vl); if (showingArrivalsFromIntent){ //do nothing but exclude a case }else if (savedInstanceState==null) { //we are not restarting the activity from nothing if (vl.equals("map")) { requestMapFragment(false); } else if (vl.equals("favorites")) { checkAndShowFavoritesFragment(getSupportFragmentManager(), false); } else if (vl.equals("lines")) { showLinesFragment(getSupportFragmentManager(), false, null); } else { showMainFragment(false); } } onCreateComplete = true; //last but not least, set the good default values checkApplyDefaultSettingsValues(); // handle the device "insets" ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.rootRelativeLayout), (v, windowInsets) -> { Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); // Apply the insets as a margin to the view. This solution sets only the // bottom, left, and right dimensions, but you can apply whichever insets are // appropriate to your layout. You can also update the view padding if that's // more appropriate. ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) v.getLayoutParams(); mlp.leftMargin = insets.left; mlp.bottomMargin = insets.bottom; mlp.rightMargin = insets.right; v.setLayoutParams(mlp); //set for toolbar //mlp = (ViewGroup.MarginLayoutParams) mToolbar.getLayoutParams(); //mlp.topMargin = insets.top; //mToolbar.setLayoutParams(mlp); mToolbar.setPadding(0, insets.top, 0, 0); // Return CONSUMED if you don't want the window insets to keep passing // down to descendant views. return WindowInsetsCompat.CONSUMED; }); /* ViewCompat.setOnApplyWindowInsetsListener(mToolbar, (v, windowInsets) -> { Insets statusBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars()); // Apply the insets as a margin to the view. ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) v.getLayoutParams(); mlp.topMargin = statusBarInsets.top; v.setLayoutParams(mlp); v.setPadding(0, statusBarInsets.top, 0, 0); // Return CONSUMED if you don't want the window insets to keep passing // down to descendant views. return WindowInsetsCompat.CONSUMED; }); */ //to properly handle IME WindowInsetsControllerCompat insetsController = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); if (insetsController != null) { insetsController.setSystemBarsBehavior( WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE ); } - //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 // and will not render the hamburger icon without it. return new ActionBarDrawerToggle(this, mDrawer, toolbar, R.string.drawer_open, R.string.drawer_close); } private boolean checkIfNeedSpecialUpgradeDB(){ final GtfsDatabase gtfsDB = GtfsDatabase.Companion.getGtfsDatabase(this); final int db_version = gtfsDB.getOpenHelper().getReadableDatabase().getVersion(); boolean dataUpdateRequested = false; final SharedPreferences theShPr = getMainSharedPreferences(); final int old_version = PreferencesHolder.getGtfsDBVersion(theShPr); Log.d(DEBUG_TAG, "GTFS Database: old version is "+old_version+ ", new version is "+db_version); if (old_version < db_version){ //decide update conditions in the future if(old_version < 2 && db_version >= 2) { dataUpdateRequested = true; DBUpdateWorker.requestDBUpdateUniqueWork(this, true); } PreferencesHolder.setGtfsDBVersion(theShPr, db_version); } return dataUpdateRequested; } /** * Setup drawer actions * @param navigationView the navigation view on which to set the callbacks */ private void setupDrawerContent(NavigationView navigationView) { navigationView.setNavigationItemSelectedListener( menuItem -> { if (menuItem.getItemId() == R.id.drawer_action_settings) { Log.d("MAINBusTO", "Pressed button preferences"); closeDrawerIfOpen(); startActivity(new Intent(ActivityPrincipal.this, ActivitySettings.class)); return true; } else if(menuItem.getItemId() == R.id.nav_favorites_item){ closeDrawerIfOpen(); //get Fragment checkAndShowFavoritesFragment(getSupportFragmentManager(), true); return true; } else if(menuItem.getItemId() == R.id.nav_arrivals){ closeDrawerIfOpen(); showMainFragment(true); return true; } else if(menuItem.getItemId() == R.id.nav_map_item){ closeDrawerIfOpen(); requestMapFragment(true); return true; } else if (menuItem.getItemId() == R.id.nav_lines_item) { closeDrawerIfOpen(); showLinesFragment(getSupportFragmentManager(), true,null); return true; } else if(menuItem.getItemId() == R.id.drawer_action_info) { closeDrawerIfOpen(); startActivity(new Intent(ActivityPrincipal.this, ActivityAbout.class)); return true; } //selectDrawerItem(menuItem); Log.d(DEBUG_TAG, "pressed item "+menuItem); return true; }); } private void closeDrawerIfOpen(){ if (mDrawer.isDrawerOpen(GravityCompat.START)) mDrawer.closeDrawer(GravityCompat.START); } // `onPostCreate` called when activity start-up is complete after `onStart()` // NOTE 1: Make sure to override the method with only a single `Bundle` argument // Note 2: Make sure you implement the correct `onPostCreate(Bundle savedInstanceState)` method. // There are 2 signatures and only `onPostCreate(Bundle state)` shows the hamburger icon. @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); // Sync the toggle state after onRestoreInstanceState has occurred. drawerToggle.syncState(); } @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); // Pass any configuration change to the drawer toggles drawerToggle.onConfigurationChanged(newConfig); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.principal_menu, menu); MenuItem experimentsMenuItem = menu.findItem(R.id.action_experiments); SharedPreferences shPr = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); boolean exper_On = shPr.getBoolean(getString(R.string.pref_key_experimental), false); experimentsMenuItem.setVisible(exper_On); return super.onCreateOptionsMenu(menu); } //requesting permissions @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode==STORAGE_PERMISSION_REQ){ final String storagePerm = Manifest.permission.WRITE_EXTERNAL_STORAGE; if (permissionDoneRunnables.containsKey(storagePerm)) { Runnable toRun = permissionDoneRunnables.get(storagePerm); if (toRun != null) toRun.run(); permissionDoneRunnables.remove(storagePerm); } if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(DEBUG_TAG, "Permissions check: " + Arrays.toString(permissions)); if (permissionDoneRunnables.containsKey(storagePerm)) { Runnable toRun = permissionDoneRunnables.get(storagePerm); if (toRun != null) toRun.run(); permissionDoneRunnables.remove(storagePerm); } } else { //permission denied showToastMessage(R.string.permission_storage_maps_msg, false); } } } @Override public boolean onOptionsItemSelected(@NonNull MenuItem item) { int[] cases = {R.id.nav_arrivals, R.id.nav_favorites_item}; Log.d(DEBUG_TAG, "Item pressed"); if (item.getItemId() == android.R.id.home) { mDrawer.openDrawer(GravityCompat.START); return true; } if (drawerToggle.onOptionsItemSelected(item)) { return true; } return super.onOptionsItemSelected(item); } /*@Override public void onBackPressed() { if (!activityCustomBackPressed()) super.onBackPressed(); } */ private boolean activityCustomBackPressed(){ boolean resolved = true; Fragment shownFrag = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); if (mDrawer.isDrawerOpen(GravityCompat.START)) mDrawer.closeDrawer(GravityCompat.START); else if(shownFrag != null && shownFrag.isVisible() && shownFrag.getChildFragmentManager().getBackStackEntryCount() > 0){ //if we have been asked to show a stop from another fragment, we should go back even in the main if(shownFrag instanceof MainScreenFragment){ //we have to stop the arrivals reload ((MainScreenFragment) shownFrag).cancelReloadArrivalsIfNeeded(); } shownFrag.getChildFragmentManager().popBackStack(); if(showingMainFragmentFromOther && getSupportFragmentManager().getBackStackEntryCount() > 0){ getSupportFragmentManager().popBackStack(); Log.d(DEBUG_TAG, "Popping main back stack also"); } } else if (getSupportFragmentManager().getBackStackEntryCount() > 0) { getSupportFragmentManager().popBackStack(); Log.d(DEBUG_TAG, "Popping main frame backstack for fragments"); } else{ resolved = false; } return resolved; } /** * Create and show the SnackBar with the message * The fragment shown points to which view to attach the snackbar */ private void createDefaultSnackbar() { View baseView = null; boolean showSnackbar = true; final Fragment frag = getSupportFragmentManager().findFragmentById(R.id.mainActContentFrame); if (frag instanceof ScreenBaseFragment){ baseView = ((ScreenBaseFragment) frag).getBaseViewForSnackBar(); showSnackbar = ((ScreenBaseFragment) frag).showSnackbarOnDBUpdate(); } if (baseView == null) baseView = findViewById(R.id.mainActContentFrame); //if (baseView == null) Log.e(DEBUG_TAG, "baseView null for default snackbar, probably exploding now"); if (baseView !=null && showSnackbar) { this.snackbar = Snackbar.make(baseView, R.string.database_update_msg_inapp, Snackbar.LENGTH_INDEFINITE); if (frag instanceof ScreenBaseFragment){ ((ScreenBaseFragment) frag).setSnackbarPropertiesBeforeShowing(this.snackbar); } this.snackbar.show(); } else{ Log.e(DEBUG_TAG, "Asked to show the snackbar but the baseView is null"); } } /** * Show the fragment by adding it to the backstack * @param fraMan the fragmentManager * @param fragment the fragment */ private static void showMainFragment(FragmentManager fraMan, MainScreenFragment fragment, boolean addToBackStack){ FragmentTransaction ft = fraMan.beginTransaction() .replace(R.id.mainActContentFrame, fragment, MainScreenFragment.FRAGMENT_TAG) .setReorderingAllowed(false) /*.setCustomAnimations( R.anim.slide_in, // enter R.anim.fade_out, // exit R.anim.fade_in, // popEnter R.anim.slide_out // popExit )*/ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); if (addToBackStack) ft.addToBackStack(null); ft.commit(); } /** * Show the fragment by adding it to the backstack * @param fraMan the fragmentManager * @param arguments args for the fragment */ private static void createShowMainFragment(FragmentManager fraMan,@Nullable Bundle arguments, boolean addToBackStack){ FragmentTransaction ft = fraMan.beginTransaction() .replace(R.id.mainActContentFrame, MainScreenFragment.class, arguments, MainScreenFragment.FRAGMENT_TAG) .setReorderingAllowed(false) /*.setCustomAnimations( R.anim.slide_in, // enter R.anim.fade_out, // exit R.anim.fade_in, // popEnter R.anim.slide_out // popExit )*/ .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); if (addToBackStack) ft.addToBackStack(null); ft.commit(); } private void requestMapFragment(final boolean allowReturn){ // starting from Android 11, we don't need to have the STORAGE permission anymore for the map cache /*if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R){ //nothing to do Log.d(DEBUG_TAG, "Build codes allow the showing of the map"); createAndShowMapFragment(null, allowReturn); return; } final String permission = Manifest.permission.WRITE_EXTERNAL_STORAGE; int result = askForPermissionIfNeeded(permission, STORAGE_PERMISSION_REQ); Log.d(DEBUG_TAG, "Permission for storage: "+result); switch (result) { case PERMISSION_OK: createAndShowMapFragment(null, allowReturn); break; case PERMISSION_ASKING: permissionDoneRunnables.put(permission, () -> createAndShowMapFragment(null, allowReturn)); break; case PERMISSION_NEG_CANNOT_ASK: String storage_perm = getString(R.string.storage_permission); String text = getString(R.string.too_many_permission_asks, storage_perm); Toast.makeText(getApplicationContext(),text, Toast.LENGTH_LONG).show(); } */ //The permissions are handled in the MapLibreFragment instead createAndShowMapFragment(null, allowReturn); } private static void checkAndShowFavoritesFragment(FragmentManager fragmentManager, boolean addToBackStack){ FragmentTransaction ft = fragmentManager.beginTransaction(); Fragment fragment = fragmentManager.findFragmentByTag(TAG_FAVORITES); if(fragment!=null){ ft.replace(R.id.mainActContentFrame, fragment, TAG_FAVORITES); }else{ //use new method ft.replace(R.id.mainActContentFrame,FavoritesFragment.class,null,TAG_FAVORITES); } if (addToBackStack) ft.addToBackStack("favorites_main"); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .setReorderingAllowed(false); ft.commit(); } private static void showLinesFragment(@NonNull FragmentManager fragmentManager, boolean addToBackStack, @Nullable Bundle fragArgs){ FragmentTransaction ft = fragmentManager.beginTransaction(); Fragment f = fragmentManager.findFragmentByTag(LinesGridShowingFragment.FRAGMENT_TAG); if(f!=null){ ft.replace(R.id.mainActContentFrame, f, LinesGridShowingFragment.FRAGMENT_TAG); }else{ //use new method ft.replace(R.id.mainActContentFrame,LinesGridShowingFragment.class,fragArgs, LinesGridShowingFragment.FRAGMENT_TAG); } if (addToBackStack) ft.addToBackStack("linesGrid"); ft.setReorderingAllowed(true) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit(); } private void showMainFragment(boolean addToBackStack){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); final MainScreenFragment mainScreenFragment; if (fragment==null | !(fragment instanceof MainScreenFragment)){ createShowMainFragment(fraMan, null, addToBackStack); } else if(!fragment.isVisible()){ mainScreenFragment = (MainScreenFragment) fragment; showMainFragment(fraMan, mainScreenFragment, addToBackStack); Log.d(DEBUG_TAG, "Found the main fragment"); } else{ mainScreenFragment = (MainScreenFragment) fragment; } //return mainScreenFragment; } @Nullable private MainScreenFragment getMainFragmentIfVisible(){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); if (fragment!= null && fragment.isVisible()) return (MainScreenFragment) fragment; else return null; } @Override public void showFloatingActionButton(boolean yes) { //TODO } /* public void setDrawerSelectedItem(String fragmentTag){ switch (fragmentTag){ case MainScreenFragment.FRAGMENT_TAG: mNavView.setCheckedItem(R.id.nav_arrivals); break; case MapFragment.FRAGMENT_TAG: break; case FavoritesFragment.FRAGMENT_TAG: mNavView.setCheckedItem(R.id.nav_favorites_item); break; } }*/ @Override public void readyGUIfor(FragmentKind fragmentType) { MainScreenFragment mainFragmentIfVisible = getMainFragmentIfVisible(); if (mainFragmentIfVisible!=null){ mainFragmentIfVisible.readyGUIfor(fragmentType); } int titleResId; switch (fragmentType){ case MAP: mNavView.setCheckedItem(R.id.nav_map_item); titleResId = R.string.map; break; case FAVORITES: mNavView.setCheckedItem(R.id.nav_favorites_item); titleResId = R.string.nav_favorites_text; break; case ARRIVALS: titleResId = R.string.nav_arrivals_text; mNavView.setCheckedItem(R.id.nav_arrivals); break; case STOPS: titleResId = R.string.stop_search_view_title; mNavView.setCheckedItem(R.id.nav_arrivals); break; case MAIN_SCREEN_FRAGMENT: case NEARBY_STOPS: case NEARBY_ARRIVALS: titleResId=R.string.app_name_full; mNavView.setCheckedItem(R.id.nav_arrivals); break; case LINES: titleResId=R.string.lines; mNavView.setCheckedItem(R.id.nav_lines_item); break; default: titleResId = 0; } if(getSupportActionBar()!=null && titleResId!=0) getSupportActionBar().setTitle(titleResId); } @Override public void requestArrivalsForStopID(String ID) { //register if the request came from the main fragment or not MainScreenFragment probableFragment = getMainFragmentIfVisible(); showingMainFragmentFromOther = (probableFragment==null); if (showingMainFragmentFromOther){ FragmentManager fraMan = getSupportFragmentManager(); Fragment fragment = fraMan.findFragmentByTag(MainScreenFragment.FRAGMENT_TAG); Log.d(DEBUG_TAG, "Requested main fragment, not visible. Search by TAG returned: "+fragment); if(fragment!=null){ //the fragment is there but not shown probableFragment = (MainScreenFragment) fragment; // set the flag probableFragment.setSuppressArrivalsReload(true); showMainFragment(fraMan, probableFragment, true); probableFragment.requestArrivalsForStopID(ID); } else { // we have no fragment final Bundle args = new Bundle(); args.putString(MainScreenFragment.PENDING_STOP_SEARCH, ID); //if onCreate is complete, then we are not asking for the first showing fragment boolean addtobackstack = onCreateComplete; createShowMainFragment(fraMan, args ,addtobackstack); } } else { //the MainScreeFragment is shown, nothing to do probableFragment.requestArrivalsForStopID(ID); } mNavView.setCheckedItem(R.id.nav_arrivals); } @Override public void openLineFromStop(String routeGtfsId, @Nullable String stopIDFrom){ readyGUIfor(FragmentKind.LINES); FragmentTransaction tr = getSupportFragmentManager().beginTransaction(); tr.replace(R.id.mainActContentFrame, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgs(routeGtfsId, stopIDFrom)); tr.addToBackStack("LineFromStop-"+routeGtfsId); tr.commit(); } @Override public void openLineFromVehicle(String routeGtfsId, @Nullable String optionalPatternId, @Nullable Bundle args) { readyGUIfor(FragmentKind.LINES); FragmentTransaction tr = getSupportFragmentManager().beginTransaction(); tr.replace(R.id.mainActContentFrame, LinesDetailFragment.class, LinesDetailFragment.Companion.makeArgsPattern(routeGtfsId, optionalPatternId, args)); tr.addToBackStack("LineFromOther-"+routeGtfsId); tr.commit(); } @Override public void toggleSpinner(boolean state) { MainScreenFragment probableFragment = getMainFragmentIfVisible(); if (probableFragment!=null){ probableFragment.toggleSpinner(state); } } @Override public void enableRefreshLayout(boolean yes) { MainScreenFragment probableFragment = getMainFragmentIfVisible(); if (probableFragment!=null){ probableFragment.enableRefreshLayout(yes); } } @Override public void showMapCenteredOnStop(Stop stop) { createAndShowMapFragment(stop, true); } //Map Fragment stuff void createAndShowMapFragment(@Nullable Stop stop, boolean addToBackStack){ final FragmentManager fm = getSupportFragmentManager(); final FragmentTransaction ft = fm.beginTransaction(); final MapLibreFragment fragment = MapLibreFragment.newInstance(stop); ft.replace(R.id.mainActContentFrame, fragment, MapLibreFragment.FRAGMENT_TAG); if (addToBackStack) ft.addToBackStack(null); ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE); ft.commit(); } void startIntroductionActivity(){ Intent intent = new Intent(ActivityPrincipal.this, ActivityIntro.class); intent.putExtra(ActivityIntro.RESTART_MAIN, false); startActivity(intent); } class ToolbarItemClickListener implements Toolbar.OnMenuItemClickListener{ private final Context activityContext; public ToolbarItemClickListener(Context activityContext) { this.activityContext = activityContext; } @Override public boolean onMenuItemClick(MenuItem item) { final int id = item.getItemId(); if(id == R.id.action_about){ startActivity(new Intent(ActivityPrincipal.this, ActivityAbout.class)); return true; } else if (id == R.id.action_hack) { openIceweasel(getString(R.string.hack_url), activityContext); return true; } else if (id == R.id.action_source){ openIceweasel("https://gitpull.it/source/libre-busto/", activityContext); return true; } else if (id == R.id.action_licence){ openIceweasel("https://www.gnu.org/licenses/gpl-3.0.html", activityContext); return true; } else if (id == R.id.action_experiments) { startActivity(new Intent(ActivityPrincipal.this, ActivityExperiments.class)); return true; } else if (id == R.id.action_tutorial) { startIntroductionActivity(); return true; } return false; } } + @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 @@ -1,121 +1,121 @@ package it.reyboz.bustorino.backend; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Build; import androidx.core.app.NotificationCompat; import it.reyboz.bustorino.R; public class Notifications { public static final String DEFAULT_CHANNEL_ID ="Default"; public static final String DB_UPDATE_CHANNELS_ID ="Database Update"; public static final String MATO_LIVE_POSITIONS_CHANNEL="Live Positions"; //match this value to the one used by the MQTTAndroidClient MANUALLY public static void createDefaultNotificationChannel(Context context) { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { CharSequence name = context.getString(R.string.default_notification_channel); String description = context.getString(R.string.default_notification_channel_description); int importance = NotificationManager.IMPORTANCE_DEFAULT; NotificationChannel channel = new NotificationChannel(DEFAULT_CHANNEL_ID, name, importance); channel.setDescription(description); // Register the channel with the system; you can't change the importance // or other notification behaviors after this NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } /** * Register a notification channel on Android Oreo and above * @param con a Context * @param name channel name * @param description channel description * @param importance channel importance (from NotificationManager) * @param ID channel ID */ public static void createNotificationChannel(Context con, String name, String description, int importance, String ID){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel(ID, name, importance); channel.setDescription(description); // Register the channel with the system; you can't change the importance // or other notification behaviors after this NotificationManager notificationManager = con.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } - 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) .setOngoing(true) .setAutoCancel(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_MIN) .setContentTitle(context.getString(R.string.app_name)) .setLocalOnly(true) .setVisibility(NotificationCompat.VISIBILITY_SECRET) .setContentText(title) .build(); } public static Notification makeLivePositionsNotification(Context context,String title){ return new NotificationCompat.Builder(context, Notifications.MATO_LIVE_POSITIONS_CHANNEL) //.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), Constants.PENDING_INTENT_FLAG_IMMUTABLE)) .setSmallIcon(R.drawable.ic_bus_stilized_transparent) .setOngoing(true) .setAutoCancel(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_MIN) .setContentTitle(context.getString(R.string.app_name)) .setLocalOnly(true) .setVisibility(NotificationCompat.VISIBILITY_SECRET) .setContentText(title) .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){ return makeLivePositionsNotification(context, context.getString(R.string.mqtt_notification_text)); } public static void cancelNotification(Context context, int notificationID){ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); manager.cancel(notificationID); } public static void createDBNotificationChannelIfNeeded(Context context){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( Notifications.DB_UPDATE_CHANNELS_ID, context.getString(R.string.database_notification_channel), NotificationManager.IMPORTANCE_MIN ); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } public static void createLivePositionsChannel(Context context){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( Notifications.MATO_LIVE_POSITIONS_CHANNEL, context.getString(R.string.live_positions_notification_channel), NotificationManager.IMPORTANCE_MIN ); channel.setDescription(context.getString(R.string.live_positions_notification_channel_desc)); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } } 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 @@ -1,83 +1,79 @@ /* BusTO - Backend components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.backend.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 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){ return Response.error(RequestError(Fetcher.Result.PARSER_ERROR)) } if (response.statusCode == 404){ return Response.error(RequestError(Fetcher.Result.SERVER_ERROR_404)) } else if (response.statusCode != 200){ return Response.error(RequestError(Fetcher.Result.SERVER_ERROR)) } val gtfsreq = GtfsRealtime.FeedMessage.parseFrom(response.data) val positionList = ArrayList() if (gtfsreq.hasHeader() && gtfsreq.entityCount>0){ for (i in 0 until gtfsreq.entityCount){ val entity = gtfsreq.getEntity(i) if (entity.hasVehicle()){ positionList.add(LivePositionUpdate(entity.vehicle)) } } } return Response.success(positionList, HttpHeaderParser.parseCacheHeaders(response)) } override fun deliverResponse(response: ArrayList?) { listener.onResponse(response) } override fun parseNetworkError(volleyError: VolleyError?): VolleyError? { return super.parseNetworkError(volleyError) } 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?) } fun interface ErrorListener: Response.ErrorListener } class RequestError(val result: Fetcher.Result): VolleyError() } \ No newline at end of file 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 @@ -1,82 +1,87 @@ /* BusTO - Backend components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.backend.gtfs; import androidx.core.util.Pair; import it.reyboz.bustorino.backend.FiveTNormalizer; 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 String toParse = routeID; if(explo.length>1) { toParse = explo[1]; } return toParse; } public static Pair getRouteInfoFromGTFS(String routeID){ String[] explo = routeID.split(":"); //default is String toParse = routeID; if(explo.length>1) { toParse = explo[1]; } ServiceType serviceType=ServiceType.UNKNOWN; final int length = toParse.length(); final char v =toParse.charAt(length-1); switch (v){ case 'E': serviceType = ServiceType.EXTRAURBANO; break; case 'F': serviceType = ServiceType.FERROVIA; break; case 'T': serviceType = ServiceType.TURISTICO; break; case 'U': serviceType=ServiceType.URBANO; } //boolean barrato=false; String num = toParse.substring(0, length-1); /*if(toParse.charAt(length-2)=='B'){ //is barrato barrato = true; num = toParse.substring(0,length-2)+" /"; }else { num = toParse.substring(0,length-1); }*/ return new Pair<>(serviceType,num); } public static String getLineNameFromGtfsID(String routeID){ return getRouteInfoFromGTFS(routeID).second; } public static String lineNameDisplayFromGtfsID(String routeID){ String name = getRouteInfoFromGTFS(routeID).second; String altName = FiveTNormalizer.routeInternalToDisplay(name); if (altName==null) //WTF WHY DOES IT HAVE TO BE NULL return name; else return altName; } } 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 @@ -1,490 +1,490 @@ package it.reyboz.bustorino.backend.mato import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.lifecycle.LifecycleOwner import com.hivemq.client.mqtt.MqttClient import com.hivemq.client.mqtt.lifecycle.MqttClientAutoReconnect import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient import com.hivemq.client.mqtt.mqtt3.Mqtt3ClientBuilder import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish import it.reyboz.bustorino.BuildConfig import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import org.json.JSONArray import org.json.JSONException import java.lang.ref.WeakReference import java.util.* import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock typealias PositionsMap = HashMap > class MQTTMatoClient(){ private var isStarted = false //private var subscribedToAll = false private var client: Mqtt3AsyncClient? = null //private var clientID = "" private val respondersMap = HashMap>>() private val currentPositions = PositionsMap() private val positionsLock = ReentrantLock() private lateinit var lifecycle: LifecycleOwner //TODO: remove class reference to context (always require context in all methods) private var context: Context?= null //private var connectionTrials = 0 //private var notification: Notification? = null private fun connect(context: Context, onConnect: OnConnect){ //val clientID = "mqtt-explorer-${getRandomString(8)}"//"mqttjs_${getRandomString(8)}" if (this.context ==null) this.context = context.applicationContext //notification = Notifications.makeMQTTServiceNotification(context) val mclient = makeClientBuilder().buildAsync() Log.d(DEBUG_TAG, "Connecting to MQTT server") //actually connect val mess = mclient.connect().whenComplete { subAck, throwable -> if(throwable!=null){ Log.w(DEBUG_TAG,"Failed to connect to MQTT server:") Log.w(DEBUG_TAG,throwable.toString()) } else{ isStarted = true Log.d(DEBUG_TAG, "Connected to MQTT server") onConnect.onConnectSuccess() } } client = mclient /* if (mess.returnCode != Mqtt3ConnAckReturnCode.SUCCESS){ Log.w(DEBUG_TAG,"Failed to connect to MQTT client") return false } else{ client = blockingClient.toAsync() isStarted = true return true } */ } /*override fun connectComplete(reconnect: Boolean, serverURI: String?) { Log.d(DEBUG_TAG, "Connected to server, reconnect: $reconnect") Log.d(DEBUG_TAG, "Have listeners: $respondersMap") } */ private fun subscribeMQTTTopic(topic: String){ if(context==null){ Log.e(DEBUG_TAG, "Trying to connect but context is null") return } client!!.subscribeWith() .topicFilter(topic).callback {publish -> messageArrived(publish.topic.toString(), publish) }.send().whenComplete { subAck, throwable-> if(throwable != null){ Log.e(DEBUG_TAG, "Error while subscribing to topic $topic", throwable) } else{ //add the responder to the list Log.d(DEBUG_TAG, "Subscribed to topic $topic, responders ready: ${respondersMap[topic]}") } } } private fun subscribeTopicAddResponder(topic: String, responderWR: WeakReference, lineId: String){ if (!respondersMap.contains(lineId)){ respondersMap[lineId] = ArrayList() } respondersMap[lineId]!!.add(responderWR) subscribeMQTTTopic(topic) } fun startAndSubscribe(lineId: String, responder: MQTTMatoListener, context: Context): Boolean{ //start the client, and then subscribe to the topic val topic = mapTopic(lineId) val vrResp = WeakReference(responder) this.context = context.applicationContext synchronized(this) { if(!isStarted || (client == null)){ connect(context.applicationContext) { //when connection is done, run this subscribeTopicAddResponder(topic, vrResp, lineId) } //wait for connection } else { subscribeTopicAddResponder(topic, vrResp, lineId) } //recheck if it is started } return true } fun stopMatoRequests(responder: MQTTMatoListener){ var removed = false for ((lineTopic,responderList)in respondersMap.entries){ val oldSize = responderList.size responderList.removeIf { (it.get()==null) || (it.get()==responder) } val diffLength = responderList.size - oldSize if(diffLength > 0) Log.d(DEBUG_TAG, "Removed $diffLength listeners for topic $lineTopic, listeners: $responderList") //if (done) break if (responderList.isEmpty()){ //actually unsubscribe try { val topic = mapTopic(lineTopic) client?.run{ unsubscribeWith().addTopicFilter(topic).send().whenComplete { subAck, throwable -> if (throwable!=null){ //error occurred Log.e(DEBUG_TAG, "Error while unsubscribing to topic $topic",throwable) } } } } catch (e: Exception){ Log.e(DEBUG_TAG, "Tried unsubscribing but there was an error in the client library:\n$e") } } removed = (diffLength>0) || removed } // check responders map, remove lines that have no responders respondersMap.entries.removeIf { it.value.isEmpty() } Log.d(DEBUG_TAG, "Removed: $removed, respondersMap: $respondersMap") } /** * Cancel the notification */ fun removeNotification(context: Context){ val notifManager = context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notifManager.cancel(MQTT_NOTIFICATION_ID) } private fun sendUpdateToResponders(responders: ArrayList>): Int{ //var sent = false var count = 0 for (wrD in responders) { if (wrD.get() == null) { Log.d(DEBUG_TAG, "Removing weak reference") responders.remove(wrD) } else { val resp = wrD.get()!! resp.onStatusUpdate(LivePositionsServiceStatus.OK) positionsLock.withLock { resp.onUpdateReceived(currentPositions) } //sent = true count++ } } return count } private fun sendStatusToResponders(status: LivePositionsServiceStatus){ val responders = respondersMap for (els in respondersMap.values) for (wrD in els) { if (wrD.get() == null) { Log.d(DEBUG_TAG, "Removing weak reference") els.remove(wrD) } else { wrD.get()!!.onStatusUpdate(status) //sent = true } } } /*override fun connectionLost(cause: Throwable?) { var doReconnect = false for ((line,elms) in respondersMap.entries){ if(!elms.isEmpty()){ doReconnect = true break } } if (!doReconnect){ Log.d(DEBUG_TAG, "Disconnected, but no responders to give the positions, avoid reconnecting") //finish here return } Log.w(DEBUG_TAG, "Lost connection in MQTT Mato Client") synchronized(this){ // isStarted = false //var i = 0 // while(i < 20 && !isStarted) { connect(context!!, object: IMqttActionListener{ override fun onSuccess(asyncActionToken: IMqttToken?) { //relisten to messages for ((line,elms) in respondersMap.entries){ val topic = mapTopic(line) if(elms.isEmpty()) respondersMap.remove(line) else { client!!.subscribe(topic, QoS.AtMostOnce.value, null, null) Log.d(DEBUG_TAG, "Resubscribed with topic $topic") } } Log.d(DEBUG_TAG, "Reconnected to MQTT Mato Client") } override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) { Log.w(DEBUG_TAG, "Failed to reconnect to MQTT server") } }) } } */ fun messageArrived(topic: String?, message: Mqtt3Publish) { if (topic==null) return //Log.d(DEBUG_TAG,"Arrived message on topic $topic, ${String(message.payload)}") parseMessageAndAddToList(topic, message) //GlobalScope.launch { } } private fun parseMessageAndAddToList(topic: String, message: Mqtt3Publish){ //val mqttTopic = message.topic //Log.d(DEBUG_TAG, "Topic of message received: $mqttTopic") val vals = topic.split("/") val lineId = vals[1] val vehicleId = vals[2] val timestamp = makeUnixTimestamp() val messString = String(message.payloadAsBytes) if(BuildConfig.DEBUG) Log.d(DEBUG_TAG, "Received message on topic: $topic") try { val jsonList = JSONArray(messString) if(jsonList.get(4)==null){ Log.d(DEBUG_TAG, "We have null tripId: line $lineId veh $vehicleId: $jsonList") sendStatusToResponders(LivePositionsServiceStatus.NO_POSITIONS) return } val posUpdate = LivePositionUpdate( jsonList.getString(4)+"U", null, null, lineId+"U", vehicleId, jsonList.getDouble(0), //latitude jsonList.getDouble(1), //longitude if(jsonList.get(2).equals(null)) null else jsonList.getInt(2).toFloat(), //"heading" (same as bearing?) timestamp, if(jsonList.get(6).equals(null)) null else jsonList.getInt(6).toString() //nextStop ) //add update //var valid = false positionsLock.withLock { if (!currentPositions.contains(lineId)) currentPositions[lineId] = HashMap() currentPositions[lineId]!!.let { it[vehicleId] = posUpdate //valid = true } } //sending //Log.d(DEBUG_TAG, "Parsed update on topic $topic, line $lineId, responders $respondersMap") var cc = 0 if (LINES_ALL in respondersMap.keys) { val count = sendUpdateToResponders(respondersMap[LINES_ALL]!!) cc +=count } if(lineId in respondersMap.keys){ cc += sendUpdateToResponders(respondersMap[lineId]!!) } //Log.d(DEBUG_TAG, "Sent to $cc responders, have $respondersMap") if(cc==0){ Log.w(DEBUG_TAG, "We have received an update but apparently there is no one to send it") var emptyResp = true for(en in respondersMap.values){ if(!en.isEmpty()){ emptyResp=false break } } //try unsubscribing to all if(emptyResp) { Log.d(DEBUG_TAG, "Unsubscribe all") //client!!.unsubscribe(LINES_ALL) } } //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) } catch (e: Exception){ Log.e(DEBUG_TAG, "Exception occurred", e) sendStatusToResponders(LivePositionsServiceStatus.ERROR_PARSING_RESPONSE) } } /** * Remove positions older than `timeMins` minutes */ fun clearOldPositions(timeMins: Int){ val currentTimeStamp = makeUnixTimestamp() var c = 0 positionsLock.withLock{ for ((k, posByVeh) in currentPositions) { /*for (t in posByVeh.keys.toList()) { // iterate over snapshot to avoid modification error val p = posByVeh[t] ?: continue if (currentTimeStamp - p.timestamp > timeMins * 60) { posByVeh.remove(t) c += 1 } } */ c+=posByVeh.entries.removeIf{ el -> currentTimeStamp - el.value.timestamp > timeMins * 60 }.let { if(it) 1 else 0 } } } Log.d(DEBUG_TAG, "Removed $c positions older than $timeMins minutes") } /*/** * Stop the service forever. Client has not to be used again!! */ fun closeClientForever(){ client.disconnect() client.close() }*/ fun disconnect(){ client?.disconnect() } companion object{ const val SERVER_ADDR="mapi.5t.torino.it" const val SERVER_PATH="/scre" const val LINES_ALL="ALL" private const val DEBUG_TAG="BusTO-MatoMQTT" //this has to match the value in MQTT library (MQTTAndroidClient) const val MQTT_NOTIFICATION_ID: Int = 77 @JvmStatic fun mapTopic(lineId: String): String{ return if(lineId== LINES_ALL || lineId == "#") "#" else{ "/${lineId}/#" } } fun getRandomString(length: Int) : String { val allowedChars = ('a'..'f') + ('0'..'9') return (1..length) .map { allowedChars.random() } .joinToString("") } interface MQTTMatoListener{ //positionsMap is a dict with line -> vehicle -> Update fun onUpdateReceived(posUpdates: PositionsMap) fun onStatusUpdate(status: LivePositionsServiceStatus) } fun getHTTPHeaders(): HashMap { val headers = HashMap() headers["Origin"] = "https://mato.muoversiatorino.it" headers["Host"] = "mapi.5t.torino.it" return headers } private fun makeClientBuilder() : Mqtt3ClientBuilder{ val clientID = "mqtt-explorer-${getRandomString(8)}" val r = MqttClient.builder() .useMqttVersion3() .identifier(clientID) .serverHost(SERVER_ADDR) .serverPort(443) .sslWithDefaultConfig() .automaticReconnect(MqttClientAutoReconnect.builder() .initialDelay(500, TimeUnit.MILLISECONDS) .maxDelay(60, TimeUnit.SECONDS).build()) .webSocketConfig() .httpHeaders(getHTTPHeaders()) .serverPath("scre") .applyWebSocketConfig() //.webSocketWithDefaultConfig() return r } private fun makeUnixTimestamp(): Long{ val timestamp = (System.currentTimeMillis() / 1000 ) return timestamp } } private fun interface OnConnect{ fun onConnectSuccess() } } /*data class MQTTPositionUpdate( val lineId: String, val vehicleId: String, val latitude: Double, val longitude: Double, val heading: Int?, val speed: Int?, val tripId: String?, val direct: Int?, val nextStop: Int?, //val full: Int? )*/ \ No newline at end of file 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 @@ -1,42 +1,52 @@ package it.reyboz.bustorino.data import android.content.Context import androidx.lifecycle.LiveData 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) //} } fun getPatternsForRouteID(routeID: String): LiveData>{ return if(routeID.isNotEmpty()) gtfsDao.getPatternsLiveDataByRouteID(routeID) else MutableLiveData(listOf()) } /** * Get the patterns with the stops lists (gtfsIDs only) */ fun getPatternsWithStopsForRouteID(routeID: String): LiveData>{ return if(routeID.isNotEmpty()) gtfsDao.getPatternsWithStopsByRouteID(routeID) else MutableLiveData(listOf()) } fun getAllRoutes(): LiveData>{ return gtfsDao.getAllRoutes() } 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 @@ -1,95 +1,115 @@ /* BusTO - Data components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.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.* /** * Class to convert values for objects into * the needed columns * * handled automatically by Room with TypeConverter */ class Converters { - @TypeConverter fun fromString(value: String?): Date? { return dateFromFmtString(value) } @TypeConverter fun dateToString(date: Date?): String? { return date?.let { stringFormat.format(it)} } @TypeConverter fun exceptionToInt(type: GtfsServiceDate.ExceptionType?): Int? { return type?.value } @TypeConverter 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" val stringFormat = SimpleDateFormat(DATE_FMT_STRING, Locale.US) fun fromStringNum(string: String?): Boolean?{ string?.let { if (it.trim() == "1") return true else if(it.trim() == "0") return false else throw Exception("Cannot convert $string to numeric value") } return null } fun fromStringNum(string: String?, defaultVal: Boolean): Boolean{ string?.let { if (it.trim() == "1") return true else if(it.trim() == "0") return false else return defaultVal } return defaultVal } fun dateFromFmtString(value: String?): Date?{ return value?.let { stringFormat.parse(it) } } fun wheelchairFromString(string: String?): WheelchairAccess{ string?.let { if (it.trim() == "1") return WheelchairAccess.SOMETIMES else if(it.trim() == "0") return WheelchairAccess.UNKNOWN else if(it.trim() == "2") return WheelchairAccess.IMPOSSIBLE else //throw Exception("Cannot convert $string to wheelchair access") } return WheelchairAccess.UNKNOWN } return WheelchairAccess.UNKNOWN } } } \ No newline at end of file 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 @@ -1,91 +1,99 @@ /* BusTO - Data components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.data.gtfs import android.content.Context import android.util.Log import androidx.room.* import androidx.room.migration.Migration @Database( entities = [ GtfsFeed::class, GtfsAgency::class, GtfsServiceDate::class, GtfsStop::class, GtfsService::class, GtfsRoute::class, GtfsStopTime::class, 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 ) @TypeConverters(Converters::class) abstract class GtfsDatabase : RoomDatabase() { abstract fun gtfsDao() : GtfsDBDao + abstract fun alertsDao(): AlertsDao + companion object{ @Volatile private var INSTANCE: GtfsDatabase? =null const val DB_NAME="gtfs_database" fun getGtfsDatabase(context: Context): GtfsDatabase{ return INSTANCE ?: synchronized(this){ val instance = Room.databaseBuilder(context.applicationContext, GtfsDatabase::class.java, DB_NAME) .addMigrations(MIGRATION_1_2) .build() INSTANCE = instance instance } } - const val VERSION = 3 + const val VERSION = 4 const val FOREIGNKEY_ONDELETE = ForeignKey.CASCADE val MIGRATION_1_2 = Migration(1,2) { Log.d("BusTO-Database", "Upgrading from version 1 to version 2 the Room Database") //create table for feeds it.execSQL("CREATE TABLE IF NOT EXISTS `gtfs_feeds` (`feed_id` TEXT NOT NULL, PRIMARY KEY(`feed_id`))") //create table gtfs_agencies it.execSQL("CREATE TABLE IF NOT EXISTS `gtfs_agencies` (`gtfs_id` TEXT NOT NULL, `ag_name` TEXT NOT NULL, `ag_url` TEXT NOT NULL, `fare_url` TEXT, `phone` TEXT, `feed_id` TEXT, PRIMARY KEY(`gtfs_id`))") //recreate routes it.execSQL("DROP TABLE IF EXISTS `routes_table`") it.execSQL("CREATE TABLE IF NOT EXISTS `routes_table` (`route_id` TEXT NOT NULL, `agency_id` TEXT NOT NULL, `route_short_name` TEXT NOT NULL, `route_long_name` TEXT NOT NULL, `route_desc` TEXT NOT NULL, `route_mode` TEXT NOT NULL, `route_color` TEXT NOT NULL, `route_text_color` TEXT NOT NULL, PRIMARY KEY(`route_id`))") //create patterns and stops it.execSQL("CREATE TABLE IF NOT EXISTS `mato_patterns` (`pattern_name` TEXT NOT NULL, `pattern_code` TEXT NOT NULL, `pattern_hash` TEXT NOT NULL, `pattern_direction_id` INTEGER NOT NULL, `pattern_route_id` TEXT NOT NULL, `pattern_headsign` TEXT, `pattern_polyline` TEXT NOT NULL, `pattern_polylength` INTEGER NOT NULL, PRIMARY KEY(`pattern_code`), FOREIGN KEY(`pattern_route_id`) REFERENCES `routes_table`(`route_id`) ON UPDATE NO ACTION ON DELETE CASCADE )") it.execSQL("CREATE TABLE IF NOT EXISTS `patterns_stops` (`pattern_gtfs_id` TEXT NOT NULL, `stop_gtfs_id` TEXT NOT NULL, `stop_order` INTEGER NOT NULL, PRIMARY KEY(`pattern_gtfs_id`, `stop_gtfs_id`, `stop_order`), FOREIGN KEY(`pattern_gtfs_id`) REFERENCES `mato_patterns`(`pattern_code`) ON UPDATE NO ACTION ON DELETE CASCADE )") } } } \ No newline at end of file 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 @@ -1,1187 +1,1205 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.ActivityResultCallback 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 import androidx.recyclerview.widget.RecyclerView import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.PolylineParser import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.map.* 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 import org.maplibre.android.geometry.LatLng import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.Property import org.maplibre.android.style.layers.Property.ICON_ROTATION_ALIGNMENT_MAP import org.maplibre.android.style.layers.PropertyFactory import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point 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 //private var isBottomSheetShowing = false private var shouldMapLocationBeReactivated = true private var toRunWhenMapReady : Runnable? = null private var mapInitialized = AtomicBoolean(false) //private var patternsSpinnerState: Parcelable? = null private lateinit var currentPatterns: List //private lateinit var map: MapView 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 private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView private var stopIDFromToShow = "" private var patternIdToShow = "" //adapter for recyclerView private val stopAdapterListener= object : StopAdapterListener { override fun onTappedStop(stop: Stop?) { if(viewModel.shouldShowMessage) { Toast.makeText(context, R.string.long_press_stop_4_options, Toast.LENGTH_SHORT).show() viewModel.shouldShowMessage=false } stop?.let { fragmentListener?.requestArrivalsForStopID(it.ID) } if(stop == null){ Log.e(DEBUG_TAG,"Passed wrong stop") } if(fragmentListener == null){ Log.e(DEBUG_TAG, "Fragment listener is null") } } override fun onLongPressOnStop(stop: Stop?): Boolean { TODO("Not yet implemented") } } private val patternsSorter = Comparator{ p1: MatoPatternWithStops, p2: MatoPatternWithStops -> if(p1.pattern.directionId != p2.pattern.directionId) return@Comparator p1.pattern.directionId - p2.pattern.directionId else return@Comparator -1*(p1.stopsIndices.size - p2.stopsIndices.size) } //map data //style and sources are in GeneralMapLibreFragment private lateinit var polylineSource: GeoJsonSource private lateinit var polyArrowSource: GeoJsonSource private var savedCameraPosition: CameraPosition? = null private var lastStopsSizeShown = 0 //BUS POSITIONS private var enablingPositionFromClick = false private var polyline: LineString? = null private val showUserPositionRequestLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") } else if (java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_COARSE_LOCATION] && java.lang.Boolean.TRUE == result[Manifest.permission.ACCESS_FINE_LOCATION]) { // We can use the position, restart location overlay if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult setMapUserLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //private var stopPosList = ArrayList() //fragment actions private var showOnTopOfLine = false private var recyclerInitDone = false private var usingMQTTPositions = true private var restoredCameraInMap = false //position of live markers private val tripMarkersAnimators = HashMap() //extra items to use the LibreMap override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() lineID = args.getString(LINEID_KEY,"") stopIDFromToShow = args.getString(STOPID_FROM_KEY, "") //can be null patternIdToShow = args.getString(PATTERN_SHOW_KEY, "") } @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { //reset statuses //isBottomSheetShowing = false //stopsLayerStarted = false lastStopsSizeShown = 0 mapInitialized.set(false) val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) //lineID = requireArguments().getString(LINEID_KEY, "") arguments?.let { lineID = it.getString(LINEID_KEY, "") stopIDFromToShow = it.getString(STOPID_FROM_KEY, "") //can be null patternIdToShow = it.getString(PATTERN_SHOW_KEY, "") Log.d(DEBUG_TAG, "LineID selected: $lineID, stopIDFromToShow: $stopIDFromToShow, patternIdToShow: $patternIdToShow") } 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) descripTextView.visibility = View.INVISIBLE //map stuff mapView = rootView.findViewById(R.id.lineMap) mapView.getMapAsync(this) // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopOrBusBottomSheet() } val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+ GtfsUtils.lineNameDisplayFromGtfsID(lineID) favoritesButton?.isClickable = true favoritesButton?.setOnClickListener { if(lineID.isNotEmpty()) PreferencesHolder.addOrRemoveLineToFavorites(requireContext(),lineID,!isLineInFavorite) } val preferences = PreferencesHolder.getMainSharedPreferences(requireContext()) val favorites = preferences.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) if(favorites!=null && favorites.contains(lineID)){ favoritesButton?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) isLineInFavorite = true } appContext = requireContext().applicationContext preferences.registerOnSharedPreferenceChangeListener(lineSharedPrefMonitor) patternsSpinner = rootView.findViewById(R.id.patternsSpinner) patternsAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, ArrayList()) patternsSpinner.adapter = patternsAdapter initializeRecyclerView() switchButton.setOnClickListener{ if(mapView.visibility == View.VISIBLE){ hideMapAndShowStopList() } else{ hideStopListAndShowMap() } } locationIcon?.let {view -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } busPositionsIconButton.setOnClickListener { LivePositionsDialogFragment().show(parentFragmentManager, "LivePositionsDialog") } //set //INITIALIZE VIEW MODELS viewModel.setRouteIDQuery(lineID) livePositionsViewModel.setGtfsLineToFilterPos(lineID, null) //observe the change, clear buses when switching position livePositionsViewModel.useMQTTPositionsLiveData.observe(viewLifecycleOwner){ useMQTT-> //Log.d(DEBUG_TAG, "Changed MQTT positions, now have to use MQTT: $useMQTT") if (isResumed) { //Log.d(DEBUG_TAG, "Deciding to switch, the current source is using MQTT: $usingMQTTPositions") if(useMQTT!=usingMQTTPositions){ // we have to switch val clearPos = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("positions_clear_on_switch_pref", true) livePositionsViewModel.clearOldPositionsUpdates() if(useMQTT){ //switching to MQTT, the GTFS positions are disabled automatically livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) } else{ //switching to GTFS RT: stop Mato, launch first request livePositionsViewModel.stopMatoUpdates() livePositionsViewModel.requestGTFSUpdates() } Log.d(DEBUG_TAG, "Should clear positions: $clearPos") if (clearPos) { livePositionsViewModel.clearAllPositions() //force clear of the viewed data if(vehShowing.isNotEmpty()) hideStopOrBusBottomSheet() clearAllBusPositionsInMap() } } } usingMQTTPositions = useMQTT } val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner, this::savePatternsToShow) /* */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> val pattern = viewModel.selectedPatternLiveData.value if (pattern == null) { Log.w(DEBUG_TAG, "The selectedPattern is null!") return@observe } if(mapView.visibility ==View.VISIBLE) { // We have the pattern and the stops here, time to display them //TODO: Decide if we should follow the camera view given by the previous screen (probably the map fragment) // use !restoredCameraInMap to do so // val shouldZoom = (shownStopInBottomSheet == null) //use this if we want to avoid zoom when we're keeping the stop open displayPatternWithStopsOnMap(pattern, stops, true) } else { if(stopsRecyclerView.visibility==View.VISIBLE) { patternShown = pattern showStopsInRecyclerView(stops) } } } viewModel.gtfsRoute.observe(viewLifecycleOwner){route-> if(route == null){ //need to close the fragment activity?.supportFragmentManager?.popBackStack() return@observe } 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") + } /* */ Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val currentShownPattern = patternShown?.pattern val patternWithStops = currentPatterns[position] Log.d(DEBUG_TAG, "request stops for pattern ${patternWithStops.pattern.code}") setPatternAndReqStops(patternWithStops) if(mapView.visibility == View.VISIBLE) { //Clear buses if we are changing direction currentShownPattern?.let { patt -> if(patt.directionId != patternWithStops.pattern.directionId){ stopAnimations() updatesByVehDict.clear() updatePositionsIcons(true) livePositionsViewModel.retriggerPositionUpdate() } if (shownStopInBottomSheet!=null){ //check if the stop is inside the new pattern /*val s = shownStopInBottomSheet!! val newPatternStops = patternWithStops.stopsIndices val filterPStops = newPatternStops.filter { ps -> ps.stopGtfsId == "gtt:${s.ID}" } if (filterPStops.isEmpty()){ hideStopOrBusBottomSheet() } */ // do another thing, just close the stop when the pattern is changed if (patt.code != patternWithStops.pattern.code){ hideStopOrBusBottomSheet() } } } } livePositionsViewModel.setGtfsLineToFilterPos(lineID, patternWithStops.pattern) } override fun onNothingSelected(p0: AdapterView<*>?) { } } Log.d(DEBUG_TAG, "Views created!") observeStatusLivePositions() return rootView } // ------------- UI switch stuff --------- private fun hideMapAndShowStopList(){ mapView.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE busPositionsIconButton?.visibility = View.GONE viewModel.setMapShowing(false) if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) hideStopOrBusBottomSheet() if(locationComponent.isLocationComponentEnabled){ locationComponent.isLocationComponentEnabled = false shouldMapLocationBeReactivated = true } else shouldMapLocationBeReactivated = false } private fun hideStopListAndShowMap(){ stopsRecyclerView.visibility = View.GONE mapView.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE busPositionsIconButton.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(usingMQTTPositions) livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else livePositionsViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) if(shouldMapLocationBeReactivated && Permissions.bothLocationPermissionsGranted(requireContext())){ locationComponent.isLocationComponentEnabled = true } } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapUserLocationEnabled(enabled: Boolean, assumePermissions: Boolean, fromClick: Boolean) { if (enabled) { val permissionOk = assumePermissions || Permissions.bothLocationPermissionsGranted(requireContext()) if (permissionOk) { Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions") locationComponent.isLocationComponentEnabled = true //locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else { if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) { //TODO: show dialog for permission rationale Toast.makeText(activity, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } Log.d(DEBUG_TAG, "Requesting permission to show user location") enablingPositionFromClick = fromClick showUserPositionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } } else{ locationComponent.isLocationComponentEnabled = false setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() //TODO: Cancel the request for the enablement of the position if needed } } } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationComponent.isLocationComponentEnabled) setMapUserLocationEnabled(false, false, true) else{ setMapUserLocationEnabled(true, false, true) } } // ------------- Map Code ------------------------- /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady var setViewAlready = false val context = requireContext() val mjson = MapLibreStyles.getJsonStyleFromAsset(context, PreferencesHolder.getMapLibreStyleFile(context)) //ViewUtils.loadJsonFromAsset(requireContext(),"map_style_good.json") activity?.run { val builder = Style.Builder().fromJson(mjson!!) mapReady.setStyle(builder) { style -> addImagesStyle(style) mapStyle = style //setupLayers(style) // Start observing data initMapUserLocation(style, mapReady, requireContext()) //if(!stopsLayerStarted) initPolylineStopsLayers(style, null) setupBusLayer(style) initSymbolManager(mapReady, style) toRunWhenMapReady?.run() toRunWhenMapReady = null mapInitialized.set(true) if(patternShown!=null){ viewModel.stopsForPatternLiveData.value?.let { Log.d(DEBUG_TAG, "Show stops from the cache") displayPatternWithStopsOnMap(patternShown!!, it, true) //Show stop from cache mapStateViewModel.lastOpenStopID.value?.let{ sID-> val s= it.filter { stop -> stop.ID==sID } if (s.isEmpty()) { if(sID.isNotEmpty()) Log.w(DEBUG_TAG,"Wanted to open stop $sID in map but it was not loaded!") } else openStopInBottomSheet(s[0]) } } } var restoredMapState = mapStateViewModel.restoreMapState(mapReady) arguments?.let { args -> // if there is a Camera State in the arguments, set it for the new camera (doesn't work yet!) if (!restoredMapState && MapCameraState.checkInBundle(args)) { val initCamState = MapCameraState.fromBundle(args) //map?.let{ MapStateViewModel.restoreMapState(mapReady, initCamState) setViewAlready = true restoredMapState = true } } restoredCameraInMap = restoredMapState } mapReady.addOnMapClickListener { point -> val screenPoint = mapReady.projection.toScreenLocation(point) val stopsNearby = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) //Log.d(DEBUG_TAG, "onMapClick, stopsNearby: $stopsNearby \nstopShown: $shownStopInBottomSheet \nbusNearby: $busNearby,") if (stopsNearby.isNotEmpty()) { val feature = stopsNearby[0] val id = feature.getStringProperty("id") val stop = viewModel.getStopByID(id) stop?.let { if (isBottomSheetShowing() || vehShowing.isNotEmpty()) { hideStopOrBusBottomSheet() } openStopInBottomSheet(it) //move camera if(it.latitude!=null && it.longitude!=null) mapReady.animateCamera(CameraUpdateFactory.newLatLng(LatLng(it.latitude!!,it.longitude!!)),750) } return@addOnMapClickListener true } else if (busNearby.isNotEmpty()){ val feature = busNearby[0] openBusFromMapClick(feature) return@addOnMapClickListener true } false } // we start requesting the bus positions now observeBusPositionUpdates() } val zoom = 12.0 val latlngTarget = LatLng(MapLibreFragment.DEFAULT_CENTER_LAT, MapLibreFragment.DEFAULT_CENTER_LON) if(!setViewAlready) mapReady.cameraPosition = savedCameraPosition ?:CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() savedCameraPosition = null if(shouldMapLocationBeReactivated) setMapUserLocationEnabled(true, false, false) } override fun showOpenStopWithSymbolLayer(): Boolean { return true } /** * Separate function to find the vehicle associated with a feature and display it */ private fun openBusFromMapClick(feature: Feature){ val vehid = feature.getStringProperty("veh") if(isBottomSheetShowing()) hideStopOrBusBottomSheet() showVehicleTripInBottomSheet(vehid) updatesByVehDict[vehid]?.let { map?.animateCamera( CameraUpdateFactory.newLatLng(LatLng(it.posUpdate.latitude, it.posUpdate.longitude)), 750 ) } } private fun observeBusPositionUpdates(){ //live bus positions livePositionsViewModel.filteredLocationUpdates.observe(viewLifecycleOwner){ pair -> //Log.d(DEBUG_TAG, "Received ${updates.size} updates for the positions") val updates = pair.first val vehiclesNotOnCorrectDir = pair.second if(mapView.visibility == View.GONE || patternShown ==null){ //DO NOTHING Log.w(DEBUG_TAG, "not doing anything because map is not visible") return@observe } //remove vehicles not on this direction removeVehiclesData(vehiclesNotOnCorrectDir) updateBusPositionsInMap(updates, hasVehicleTracking = true) { veh-> showVehicleTripInBottomSheet(veh) } //if not using MQTT positions if(!usingMQTTPositions){ livePositionsViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs livePositionsViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } } private fun showVehicleTripInBottomSheet(veh: String) { super.showVehicleTripInBottomSheet(veh) { patternCode, veh -> //this is checked in @GeneralMapLibreFragment //val data = updatesByVehDict[veh] ?: return@showVehicleTripInBottomSheet if (patternCode.isEmpty()) return@showVehicleTripInBottomSheet if (patternShown?.pattern?.code == patternCode) { //center view on vehicle updatesByVehDict[veh]?.let { up-> map?.let{ /* val c = it.cameraPosition it.moveCamera(CameraUpdateFactory.CameraPositionUpdate(c.bearing, LatLng(up.posUpdate.latitude, up.posUpdate.longitude), c.tilt,c.zoom, c.padding) ) */ it.animateCamera(CameraUpdateFactory.newLatLng(LatLng(up.posUpdate.latitude, up.posUpdate.longitude))) } } ?: { Toast.makeText(context, R.string.showing_same_direction, Toast.LENGTH_SHORT).show() } } else { showPatternWithCode(patternCode) } } } // ------- MAP LAYERS INITIALIZE ---- /** * Initialize the map layers for the stops */ private fun initPolylineStopsLayers(style: Style, arrowFeatures: FeatureCollection?){ Log.d(DEBUG_TAG, "INIT STOPS CALLED") stopsSource = GeoJsonSource(STOPS_SOURCE_ID) //val context = requireContext() val stopIcon = ResourcesCompat.getDrawable(resources,R.drawable.ball, activity?.theme)!! val imgStop = ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!! val polyIconArrow = ResourcesCompat.getDrawable(resources, R.drawable.arrow_up_box_fill, activity?.theme)!! //set the image tint //DrawableCompat.setTint(imgBus,ContextCompat.getColor(context,R.color.line_drawn_poly)) // add icons style.addImage(STOP_IMAGE_ID,stopIcon) style.addImage(POLY_ARROW, polyIconArrow) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) polylineSource = GeoJsonSource(POLYLINE_SOURCE) //lineFeature?.let { GeoJsonSource(POLYLINE_SOURCE, it) } ?: GeoJsonSource(POLYLINE_SOURCE) style.addSource(polylineSource) val color=ContextCompat.getColor(requireContext(),R.color.line_drawn_poly) //paint.style = Paint.Style.FILL_AND_STROKE //paint.strokeJoin = Paint.Join.ROUND //paint.strokeCap = Paint.Cap.ROUND val lineLayer = LineLayer(POLYLINE_LAYER, POLYLINE_SOURCE).withProperties( PropertyFactory.lineColor(color), PropertyFactory.lineWidth(5.0f), //originally 13f PropertyFactory.lineOpacity(1.0f), PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND), PropertyFactory.lineCap(Property.LINE_CAP_ROUND) ) polyArrowSource = GeoJsonSource(POLY_ARROWS_SOURCE, arrowFeatures) style.addSource(polyArrowSource) val arrowsLayer = SymbolLayer(POLY_ARROWS_LAYER, POLY_ARROWS_SOURCE).withProperties( PropertyFactory.iconImage(POLY_ARROW), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP) ) val layers = style.layers val lastLayers = layers.filter { l-> l.id.contains("city") } //Log.d(DEBUG_TAG,"Layers:\n ${style.layers.map { l -> l.id }}") Log.d(DEBUG_TAG, "City layers: ${lastLayers.map { l-> l.id }}") if(lastLayers.isNotEmpty()) style.addLayerAbove(lineLayer,lastLayers[0].id) else style.addLayerBelow(lineLayer,"label_country_1") //style.addLayerAbove(stopsLayer, POLYLINE_LAYER) style.addLayerAbove(arrowsLayer, POLYLINE_LAYER) stopsLayerStarted = true initStopsLayer(style, null, POLY_ARROWS_LAYER) } private fun filterPatternFromArgs(patterns: List): MatoPatternWithStops?{ var p: MatoPatternWithStops? = null if (patternIdToShow.isNotEmpty()){ for (patt in patterns) { if (patt.pattern.code == patternIdToShow){ p = patt } } if(p==null) Log.w(DEBUG_TAG, "We had to show the pattern with code $patternIdToShow, but we didn't find it") else Log.d(DEBUG_TAG, "Requesting to show pattern with code $patternIdToShow, found pattern ${p.pattern.code}") } // if we are loading from a stop, find it else if(stopIDFromToShow.isNotEmpty()) { val stopGtfsID = "gtt:$stopIDFromToShow" var pLength = 0 for (patt in patterns) { for (pstop in patt.stopsIndices) { if (pstop.stopGtfsId == stopGtfsID) { //found if (patt.stopsIndices.size > pLength) { p = patt pLength = patt.stopsIndices.size } //break here, we have determined this pattern has the stop we're looking for break } } } if(p==null) Log.w(DEBUG_TAG, "We had to show the pattern from stop $stopIDFromToShow, but we didn't find it") else Log.d(DEBUG_TAG, "Requesting to show pattern from stop $stopIDFromToShow, found pattern ${p.pattern.code}") } // the flag of showing pattern is not necessary anymore, we have set the pattern patternIdToShow = "" // the flag of selecting from stop needs to be used again when displaying the pattern return p } /** * Save the loaded pattern data, without the stops! */ private fun savePatternsToShow(patterns: List){ currentPatterns = patterns.sortedWith(patternsSorter) patternsAdapter?.let { it.clear() it.addAll(currentPatterns.map { p->"${p.pattern.directionId} - ${p.pattern.headsign}" }) it.notifyDataSetChanged() } val patternToShow = filterPatternFromArgs(currentPatterns) if(patternToShow!=null) { //showPattern(patternToShow) patternShown = patternToShow } patternShown?.let { showPattern(it) } } /** * Called when the position of the spinner is updated */ private fun setPatternAndReqStops(patternWithStops: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Requesting stops for pattern ${patternWithStops.pattern.code}") viewModel.selectedPatternLiveData.value = patternWithStops viewModel.currentPatternStops.value = patternWithStops.stopsIndices.sortedBy { i-> i.order } viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ //Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for (k in currentPatterns.indices) { if (currentPatterns[k].pattern.code.trim() == code) { pos = k break } } Log.d(DEBUG_TAG, "Requesting stops fro pattern $code in position: $pos") // this triggers the showing on the map / recyclerview if (pos !=-2) patternsSpinner.setSelection(pos) else Log.e(DEBUG_TAG, "Pattern with code $code not found!!") } /** * Zoom on the map to get the pattern */ private fun zoomToCurrentPattern(){ if(polyline==null) return val NULL_VALUE = -4000.0 var maxLat = NULL_VALUE var minLat = NULL_VALUE var minLong = NULL_VALUE var maxLong = NULL_VALUE polyline?.let { for(p in it.coordinates()){ val lat = p.latitude() val lon = p.longitude() // get max latitude if(maxLat == NULL_VALUE) maxLat =lat else if (maxLat < lat) maxLat = lat // find min latitude if (minLat ==NULL_VALUE) minLat = lat else if (minLat > lat) minLat = lat if(maxLong == NULL_VALUE || maxLong < lon ) maxLong = lon if (minLong == NULL_VALUE || minLong > lon) minLong = lon } val padding = 50 // Pixel di padding intorno ai limiti Log.d(DEBUG_TAG, "Setting limits of bounding box of line: $minLat -> $maxLat, $minLong -> $maxLong") val bbox = LatLngBounds.from(maxLat,maxLong, minLat, minLong) //map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), false) map?.animateCamera(CameraUpdateFactory.newLatLngBounds(bbox, padding)) } } private fun displayPatternWithStopsOnMap(patternWs: MatoPatternWithStops, stopsToSort: List, zoomToPattern: Boolean){ if(!mapInitialized.get()){ //set the runnable and do nothing else Log.d(DEBUG_TAG, "Delaying pattern display to when map is Ready: ${patternWs.pattern.code}") toRunWhenMapReady = Runnable { displayPatternWithStopsOnMap(patternWs, stopsToSort, zoomToPattern) } return } Log.d(DEBUG_TAG, "Got the stops: ${stopsToSort.map { s->s.gtfsID }}}") patternShown = patternWs //Problem: stops are not sorted val stopOrderD = patternWs.stopsIndices.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stopsToSort.sortedBy { s-> stopOrderD[s.gtfsID] } val pattern = patternWs.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) val pointsToShow = pointsList.map { Point.fromLngLat(it.longitude, it.latitude) } Log.d(DEBUG_TAG, "The polyline has ${pointsToShow.size} points to display") polyline = LineString.fromLngLats(pointsToShow) val lineFeature = Feature.fromGeometry(polyline) //Log.d(DEBUG_TAG, "Polyline in JSON is: ${lineFeature.toJson()}") // --- STOPS--- val features = ArrayList() for (s in stopsSorted){ if (s.latitude!=null && s.longitude!=null) { val loc = if (showOnTopOfLine) findOptimalPosition(s, pointsList) else LatLng(s.latitude!!, s.longitude!!) features.add( Feature.fromGeometry( Point.fromLngLat(loc.longitude, loc.latitude), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) ) } } // -- ARROWS -- //val splitPolyline = MapLibreUtils.splitPolyWhenDistanceTooBig(pointsList, 200.0) val arrowFeatures = ArrayList() val pointsIndexToShowIcon = MapLibreUtils.findPointsToPutDirectionMarkers(pointsList, stopsSorted, 750.0) for (idx in pointsIndexToShowIcon){ val pnow = pointsList[idx] val otherp = if(idx>1) pointsList[idx-1] else pointsList[idx+1] val bearing = if (idx>1) MapLibreUtils.getBearing(pointsList[idx-1], pnow) else MapLibreUtils.getBearing(pnow, pointsList[idx+1]) arrowFeatures.add(Feature.fromGeometry( Point.fromLngLat((pnow.longitude+otherp.longitude)/2, (pnow.latitude+otherp.latitude)/2 ), //average JsonObject().apply { addProperty("bearing", bearing) } )) } Log.d(DEBUG_TAG,"Have put ${features.size} stops to display") // if the layer is already started, substitute the stops inside, otherwise start it if (stopsLayerStarted) { stopsSource.setGeoJson(FeatureCollection.fromFeatures(features)) polylineSource.setGeoJson(lineFeature) polyArrowSource.setGeoJson(FeatureCollection.fromFeatures(arrowFeatures)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initPolylineStopsLayers(mapStyle, FeatureCollection.fromFeatures(arrowFeatures)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } ?:{ Log.e(DEBUG_TAG, "Stops layer is not started!!") } var reallyZoomToPattern = zoomToPattern if(stopIDFromToShow.isNotEmpty()){ //open the stop val stopfilt = stopsSorted.filter { s -> s.ID == stopIDFromToShow } if (stopfilt.isEmpty()){ Log.e(DEBUG_TAG, "Tried to show stop but it's not in the selected pattern") } else{ val stop = stopfilt[0] openStopInBottomSheet(stop) if(stop.hasCoords()) { reallyZoomToPattern = false setCameraPosition(stop.latitude!!, stop.longitude!!, 13.5) } } // Reset this to avoid checking again when showing stopIDFromToShow = "" //camera set } if(reallyZoomToPattern) zoomToCurrentPattern() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsInRecyclerView(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * This method fixes the display of the pattern, to be used when clicking on a bus */ private fun showPatternWithCode(patternId: String){ //var index = 0 Log.d(DEBUG_TAG, "Showing pattern with code $patternId ") for (i in currentPatterns.indices){ val pattStop = currentPatterns[i] if(pattStop.pattern.code == patternId){ Log.d(DEBUG_TAG, "Pattern found in position $i") //setPatternAndReqStops(pattStop) patternsSpinner.setSelection(i) break } } } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) usingMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(usingMQTTPositions) livePositionsViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else livePositionsViewModel.requestGTFSUpdates() //initialize GUI here fragmentListener?.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() mapView.onPause() if(usingMQTTPositions) livePositionsViewModel.stopMatoUpdates() pausedFragment = true //save map map?.let{ //if map is initialized mapStateViewModel.saveMapState(it) } mapStateViewModel.lastOpenStopID.postValue(shownStopInBottomSheet?.ID) } override fun onStop() { super.onStop() mapView.onStop() shouldMapLocationBeReactivated = locationComponent.isLocationComponentEnabled } override fun onDestroyView() { map?.run { Log.d(DEBUG_TAG, "Saving camera position") savedCameraPosition = cameraPosition } super.onDestroyView() Log.d(DEBUG_TAG, "Destroying the views") /*mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle?.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) */ //stopsLayerStarted = false } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(POLYLINE_LAYER) mapStyle.removeSource(POLYLINE_SOURCE) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" private const val STOPID_FROM_KEY="stopID" private const val PATTERN_SHOW_KEY ="patternIDShow" private const val DEBUG_TAG="BusTO-LineDetalFragment" fun makeArgs(lineID: String, stopIDFrom: String?): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) b.putString(STOPID_FROM_KEY, stopIDFrom) return b } fun makeArgsPattern(lineID: String, patternShow: String?, extraArgs: Bundle?): Bundle { val b= extraArgs ?: Bundle() b.putString(LINEID_KEY, lineID) b.putString(PATTERN_SHOW_KEY, patternShow) return b } fun newInstance(lineID: String?, stopIDFrom: String?) = LinesDetailFragment().apply { lineID?.let { arguments = makeArgs(it, stopIDFrom) } } @JvmStatic private fun findOptimalPosition(stop: Stop, pointsList: MutableList): LatLng{ if(stop.latitude==null || stop.longitude ==null|| pointsList.isEmpty()) throw IllegalArgumentException() val sLat = stop.latitude!! val sLong = stop.longitude!! if(pointsList.size < 2) return pointsList[0] pointsList.sortBy { utils.measuredistanceBetween(sLat, sLong, it.latitude, it.longitude) } val p1 = pointsList[0] val p2 = pointsList[1] if (p1.longitude == p2.longitude){ //Log.e(DEBUG_TAG, "Same longitude") return LatLng(sLat, p1.longitude) } else if (p1.latitude == p2.latitude){ //Log.d(DEBUG_TAG, "Same latitude") return LatLng(p2.latitude,sLong) } val m = (p1.latitude - p2.latitude) / (p1.longitude - p2.longitude) val minv = (p1.longitude-p2.longitude)/(p1.latitude - p2.latitude) val cR = p1.latitude - p1.longitude * m val longNew = (minv * sLong + sLat -cR ) / (m+minv) val latNew = (m*longNew + cR) //Log.d(DEBUG_TAG,"Stop ${stop.ID} old pos: ($sLat, $sLong), new pos ($latNew,$longNew)") return LatLng(latNew,longNew) } private const val DEFAULT_CENTER_LAT = 45.12 private const val DEFAULT_CENTER_LON = 7.6858 } } \ No newline at end of file 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 @@ -1,95 +1,94 @@ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.map import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.gtfs.GtfsDatabase import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.util.LinesNameSorter class LinesGridShowingViewModel(application: Application) : AndroidViewModel(application) { private val linesNameSorter = LinesNameSorter() private val linesComparator = Comparator { a,b -> return@Comparator linesNameSorter.compare(a.shortName, b.shortName) } private val gtfsRepo: GtfsRepository private val routesLiveData: LiveData> //= gtfsRepo.getAllRoutes() val isUrbanExpanded = MutableLiveData(true) val isExtraUrbanExpanded = MutableLiveData(false) val isTouristExpanded = MutableLiveData(false) val favoritesExpanded = MutableLiveData(true) val favoritesLinesIDs = MutableLiveData>() private val queryLiveData = MutableLiveData("") fun setLineQuery(query: String){ if(query!=queryLiveData.value) queryLiveData.value = query } fun getLineQueryValue():String{ return queryLiveData.value ?: "" } private val filteredLinesLiveData = MediatorLiveData>>() fun getLinesLiveData() = filteredLinesLiveData private fun filterLinesForQuery(lines: List, query: String): ArrayList>{ var result= lines.filter { r-> query.lowercase() in r.shortName.lowercase() } //EXCLUDE gtt:F - ferrovie (luckily, gtt does not run rail service anymore) result = result.filter { r -> r.agencyID != "gtt:F" } val out = ArrayList>() for (r in result){ out.add(Pair(r,1)) } // add those matching the query in the description for (r: GtfsRoute in lines) { if (query.lowercase() in r.description.lowercase()) { if (r !in result){ out.add(Pair(r,2)) } } } return out } init { - val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() - gtfsRepo = GtfsRepository(gtfsDao) + gtfsRepo = GtfsRepository(application) routesLiveData = gtfsRepo.getAllRoutes() filteredLinesLiveData.addSource(routesLiveData){ filteredLinesLiveData.value = filterLinesForQuery(it,queryLiveData.value ?: "" ) } filteredLinesLiveData.addSource(queryLiveData){ routesLiveData.value?.let { routes -> filteredLinesLiveData.value = filterLinesForQuery(routes, it) } } } fun setFavoritesLinesIDs(linesIds: HashSet){ favoritesLinesIDs.value = linesIds } val favoritesLines = favoritesLinesIDs.map {lineIds -> val linesList = ArrayList() if (lineIds.size == 0 || routesLiveData.value==null) return@map linesList for(line in routesLiveData.value!!){ if(lineIds.contains(line.gtfsId)) linesList.add(line) } linesList.sortWith(linesComparator) return@map linesList } } \ No newline at end of file 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 @@ -1,132 +1,131 @@ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* import it.reyboz.bustorino.backend.Result import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.NextGenDB import it.reyboz.bustorino.data.OldDataRepository import it.reyboz.bustorino.data.gtfs.GtfsDatabase import it.reyboz.bustorino.data.gtfs.GtfsRoute import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.data.gtfs.PatternStop import org.maplibre.android.geometry.LatLng import java.util.concurrent.Executors class LinesViewModel(application: Application) : AndroidViewModel(application) { private val gtfsRepo: GtfsRepository private val oldRepo: OldDataRepository //val patternsByRouteLiveData: LiveData> private val routeIDToSearch = MutableLiveData() private var lastShownPatternStops = ArrayList() val currentPatternStops = MutableLiveData>() val selectedPatternLiveData = MutableLiveData() val stopsForPatternLiveData = MutableLiveData>() private val executor = Executors.newFixedThreadPool(2) val mapShowing = MutableLiveData(true) fun setMapShowing(yes: Boolean){ mapShowing.value = yes //retrigger redraw stopsForPatternLiveData.postValue(stopsForPatternLiveData.value) } init { - val gtfsDao = GtfsDatabase.getGtfsDatabase(application).gtfsDao() - gtfsRepo = GtfsRepository(gtfsDao) + gtfsRepo = GtfsRepository(application) oldRepo = OldDataRepository(executor, NextGenDB.getInstance(application)) } val routesGTTLiveData: LiveData> by lazy{ gtfsRepo.getLinesLiveDataForFeed("gtt") } val patternsWithStopsByRouteLiveData = routeIDToSearch.switchMap { gtfsRepo.getPatternsWithStopsForRouteID(it) } val gtfsRoute = routeIDToSearch.switchMap { gtfsRepo.getRouteFromGtfsId(it) } fun setRouteIDQuery(routeID: String){ routeIDToSearch.value = routeID } fun getRouteIDQueried(): String?{ return routeIDToSearch.value } var shouldShowMessage = true fun setPatternToDisplay(patternStops: MatoPatternWithStops){ selectedPatternLiveData.value = patternStops } /** * Find the */ private fun requestStopsForGTFSIDs(gtfsIDs: List){ if (gtfsIDs.equals(lastShownPatternStops)){ //nothing to do return } oldRepo.requestStopsWithGtfsIDs(gtfsIDs) { if (it.isSuccess) { stopsForPatternLiveData.postValue(it.result) } else { Log.e("BusTO-LinesVM", "Got error on callback with stops for gtfsID") it.exception?.printStackTrace() } } lastShownPatternStops.clear() for(id in gtfsIDs) lastShownPatternStops.add(id) } fun requestStopsForPatternWithStops(patternStops: MatoPatternWithStops){ val gtfsIDs = ArrayList() for(pat in patternStops.stopsIndices){ gtfsIDs.add(pat.stopGtfsId) } requestStopsForGTFSIDs(gtfsIDs) } fun getStopByID(id:String) : Stop?{ //var stop : Stop? = null val stop = stopsForPatternLiveData.value?.let { stops -> for (s in stops){ if(s.ID == id) return@let s } return@let null } return stop } private var lastMapPos: Pair? = null fun saveMapPos(latLng: LatLng, zoom: Float){ lastMapPos = Pair(latLng, zoom) } fun getLastMapPos(): Pair? = lastMapPos /*fun getLinesGTT(): MutableLiveData> { val routesData = MutableLiveData>() viewModelScope.launch { val routes=gtfsRepo.getLinesForFeed("gtt") routesData.postValue(routes) } return routesData }*/ } \ No newline at end of file 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 @@ -1,484 +1,484 @@ /* BusTO - ViewModel components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.work.WorkInfo import androidx.work.WorkManager import com.android.volley.DefaultRetryPolicy import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Fetcher import it.reyboz.bustorino.backend.LivePositionsServiceStatus import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.backend.mato.PositionsMap import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.MatoPatternsDownloadWorker import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.gtfs.MatoPattern import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.* import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.collections.HashSet import androidx.core.content.edit import androidx.lifecycle.MutableLiveData typealias FullPositionUpdatesMap = HashMap> typealias FullPositionUpdate = Pair class LivePositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) //chain of LiveData objects: raw positions -> tripsIDs -> tripsAndPatternsInDB -> positions with patterns //this contains the raw positions updates received from the service private val positionsToBeMatchedLiveData = MutableLiveData>() private val netVolleyManager = NetworkVolleyManager.getInstance(application) private var mqttClient = MQTTMatoClient() private var lineListening = "" private var lastTimeMQTTUpdatedPositions: Long = 0 private val gtfsRtRequestRunning = MutableLiveData(false) private val lastFailedTripsRequest = HashMap() private val workManager = WorkManager.getInstance(application) private var lastRequestedDownloadTrips = MutableLiveData>() //INPUT FILTER FOR LINE private var gtfsLineToFilterPos = MutableLiveData>() var serviceStatus = MutableLiveData(LivePositionsServiceStatus.CONNECTING) private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(application) private val keySourcePositions = application.getString(R.string.pref_positions_source) private val LIVE_POS_PREF_MQTT : String private val LIVE_POS_PREF_GTFSRT :String val useMQTTPositionsLiveData: MutableLiveData init { sharedPrefs.registerOnSharedPreferenceChangeListener { shp, key -> if(key == keySourcePositions) { val newV = shp.getString(keySourcePositions, LIVE_POS_PREF_MQTT) useMQTTPositionsLiveData.postValue(newV.equals(LIVE_POS_PREF_MQTT)) Log.d(DEBUG_TI, "Changed position source to: $newV") } } LIVE_POS_PREF_MQTT = application.getString(R.string.positions_source_mqtt) LIVE_POS_PREF_GTFSRT = application.getString(R.string.positions_source_gtfsrt) useMQTTPositionsLiveData = MutableLiveData(isMQTTPositionsSelected()) } private fun isMQTTPositionsSelected(): Boolean{ val source = sharedPrefs.getString(keySourcePositions, LIVE_POS_PREF_MQTT) val useMQTT=source == LIVE_POS_PREF_MQTT Log.d(DEBUG_TI, "Init positions, source: $source, isMQTT: $useMQTT") return useMQTT } /** * Switch provider of live positions from MQTT to GTFSRT and viceversa */ fun switchPositionsSource(){ val usingMQTT = useMQTTPositionsLiveData.value!! //code that was in the MapLibreFragment useMQTTPositionsLiveData.value = !usingMQTT sharedPrefs.edit(commit = true) { putString( keySourcePositions, if (usingMQTT) LIVE_POS_PREF_GTFSRT else LIVE_POS_PREF_MQTT ) } Log.d(DEBUG_TI, "Switched positions source in ViewModel, now using MQTT: ${!usingMQTT}") serviceStatus.value = LivePositionsServiceStatus.CONNECTING } var isLastWorkResultGood = workManager .getWorkInfosForUniqueWorkLiveData(MatoTripsDownloadWorker.TAG_TRIPS).map { it -> if (it.isEmpty()) return@map false var res = true if(it[0].state == WorkInfo.State.FAILED){ val currDate = Date() res = false lastRequestedDownloadTrips.value?.let { trips-> for(tr in trips){ lastFailedTripsRequest[tr] = currDate } } } return@map res } /** * Responder to the MQTT Client */ private val matoPositionListener = object: MQTTMatoClient.Companion.MQTTMatoListener{ override fun onUpdateReceived(it: PositionsMap) { val mupds = ArrayList() if(lineListening==MQTTMatoClient.LINES_ALL){ for(sdic in it.values){ for(update in sdic.values){ mupds.add(update) } } } else{ //we're listening to one if (it.containsKey(lineListening.trim()) ){ for(up in it[lineListening]?.values!!){ mupds.add(up) } } } //avoid updating the positions too often (limit to 0.5 seconds) val time = System.currentTimeMillis() if(lastTimeMQTTUpdatedPositions == (0.toLong()) || (time-lastTimeMQTTUpdatedPositions)>500){ positionsToBeMatchedLiveData.postValue(mupds) lastTimeMQTTUpdatedPositions = time } //we have received an update, so set the status to OK serviceStatus.postValue(LivePositionsServiceStatus.OK) } override fun onStatusUpdate(status: LivePositionsServiceStatus) { serviceStatus.postValue(status) } } //find the trip IDs in the updates private val tripsIDsInUpdates = positionsToBeMatchedLiveData.map { it -> //Log.d(DEBUG_TI, "Updates map has keys ${upMap.keys}") it.map { pos -> "gtt:"+pos.tripID } } // get the trip IDs in the DB private val gtfsTripsPatternsInDB = tripsIDsInUpdates.switchMap { //Log.i(DEBUG_TI, "tripsIds in updates: ${it.size}") gtfsRepo.gtfsDao.getTripPatternStops(it) } //trip IDs to query, which are not present in the DB //REMEMBER TO OBSERVE THIS IN THE MAP val tripsGtfsIDsToQuery: LiveData> = gtfsTripsPatternsInDB.map { tripswithPatterns -> val tripNames=tripswithPatterns.map { twp-> twp.trip.tripID } Log.i(DEBUG_TI, "Have ${tripswithPatterns.size} trips in the DB") if (tripsIDsInUpdates.value!=null) return@map tripsIDsInUpdates.value!!.filter { !(tripNames.contains(it) || it.contains("null"))} else { Log.e(DEBUG_TI,"Got results for gtfsTripsInDB but not tripsIDsInUpdates??") return@map ArrayList() } } /** * This livedata object contains the final updates with patterns present in the DB */ val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> //TODO: Change the mapping in the final updates, I don't know why the key is the tripID and not the vehicle ID Log.i(DEBUG_TI, "Mapping trips and patterns") val mdict = HashMap() //missing patterns val routesToDownload = HashSet() if(positionsToBeMatchedLiveData.value!=null) for(update in positionsToBeMatchedLiveData.value!!){ val trID:String = update.tripID var found = false for(trip in tripPatterns){ if (trip.pattern == null){ //pattern is null, which means we have to download // the pattern data from MaTO routesToDownload.add(trip.trip.routeID) } if (trip.trip.tripID == "gtt:$trID"){ found = true //insert directly mdict[trID] = Pair(update,trip) break } } if (!found){ //Log.d(DEBUG_TI, "Cannot find pattern ${tr}") //give the update anyway mdict[trID] = Pair(update,null) } } //have to request download of missing Patterns if (routesToDownload.isNotEmpty()){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") //downloadMissingPatterns (ArrayList(routesToDownload)) MatoPatternsDownloadWorker.downloadPatternsForRoutes(routesToDownload.toList(), getApplication()) } return@map mdict } fun clearOldPositionsUpdates(){ //RETURN if the map is null val positionsOld = positionsToBeMatchedLiveData.value ?: return val currentTimeSecs = (System.currentTimeMillis() / 1000 ) val updatedList = ArrayList() for (up in positionsOld){ //If the time has passed, remove it if (currentTimeSecs - up.timestamp <= MAX_MINUTES_CLEAR_POSITIONS*60) //TODO decide time limit in minutes updatedList.add(up) } val diff = positionsOld.size - updatedList.size Log.d(DEBUG_TI, "Removed ${diff} positions marked as old") // Re-trigger all the LiveData chain positionsToBeMatchedLiveData.value = updatedList } fun clearAllPositions(){ positionsToBeMatchedLiveData.postValue(ArrayList()) Log.d(DEBUG_TI, "Cleared all positions in LivePositionsViewModel") } //OBSERVE THIS TO GET THE LOCATION UPDATES FILTERED val filteredLocationUpdates = MediatorLiveData>>() init { filteredLocationUpdates.addSource(updatesWithTripAndPatterns){ filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(it, gtfsLineToFilterPos.value!!)) } filteredLocationUpdates.addSource(gtfsLineToFilterPos){ Log.d(DEBUG_TI, "line to filter change to: ${gtfsLineToFilterPos.value}") updatesWithTripAndPatterns.value?.let{ ups-> filteredLocationUpdates.postValue(filterUpdatesForGtfsLine(ups, it)) //Log.d(DEBUG_TI, "Set ${ups.size} updates as new value for filteredLocation") } } } private fun clearFilteredPositions(){ filteredLocationUpdates.postValue(Pair(HashMap(), ArrayList())) } fun setGtfsLineToFilterPos(line: String, pattern: MatoPattern?){ clearFilteredPositions() gtfsLineToFilterPos.value = Pair(line, pattern) } private fun filterUpdatesForGtfsLine(updates: FullPositionUpdatesMap, linePatt: Pair): Pair, List>{ val gtfsLineId = linePatt.first val pattern = linePatt.second val updsForTripId = HashMap>() val vehicleOnWrongDirection = mutableListOf() //supporting the eventual null case when there is no need to filter if (gtfsLineId == "ALL"){ //copy the dict for ((tripId, pair) in updates.entries) { updsForTripId[tripId] = pair } } else { val filtdLineID = GtfsUtils.stripGtfsPrefix(gtfsLineId) //filter buses with direction, show those only with the same direction val directionId = pattern?.directionId ?: -100 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() for ((tripId, pair) in updates.entries) { //remove trips with wrong line val posUp = pair.first val vehicle = pair.first.vehicle if (pair.first.routeID != filtdLineID) continue if (directionId != -100 && pair.second != null && pair.second?.pattern != null) { val dir = pair.second!!.pattern!!.directionId 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) } //patternsDirections[tripId] = dir ?: -10 } else { updsForTripId[tripId] = pair //Log.d(DEBUG_TAG, "No pattern for tripID: $tripId") //patternsDirections[tripId] = -10 } } } Log.d(DEBUG_TI, "Filtered updates are ${updsForTripId.keys.size}") // Original updates directs: $patternsDirections\n return Pair(updsForTripId, vehicleOnWrongDirection) } fun requestMatoPosUpdates(line: String){ lineListening = line viewModelScope.launch { mqttClient.startAndSubscribe(line,matoPositionListener, getApplication()) //clear old positions (useful when we are coming back to the map after some time) mqttClient.clearOldPositions(MAX_MINUTES_CLEAR_POSITIONS) } //updatePositions(1000) } fun stopMatoUpdates(){ viewModelScope.launch { val tt = System.currentTimeMillis() mqttClient.stopMatoRequests(matoPositionListener) val time = System.currentTimeMillis() -tt Log.d(DEBUG_TI, "Took $time ms to unsubscribe") } } fun retriggerPositionUpdate(){ if(positionsToBeMatchedLiveData.value!=null){ positionsToBeMatchedLiveData.postValue(positionsToBeMatchedLiveData.value) } } //Gtfs Real time private val gtfsPositionsReqListener = object: GtfsRtPositionsRequest.Companion.RequestListener{ override fun onResponse(response: ArrayList?) { Log.i(DEBUG_TI,"Got response from the GTFS RT server") if (response == null){ serviceStatus.postValue(LivePositionsServiceStatus.ERROR_CONNECTION) } else response.let { it:ArrayList -> val ss: LivePositionsServiceStatus if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the GTFS RT server") ss = LivePositionsServiceStatus.NO_POSITIONS } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { positionsToBeMatchedLiveData.postValue(it) } ss = LivePositionsServiceStatus.OK } serviceStatus.postValue(ss) } gtfsRtRequestRunning.postValue(false) } } /** * Listener for the errors in downloading positions from GTFS RT */ private val positionRequestErrorListener = GtfsRtPositionsRequest.Companion.ErrorListener { Log.e(DEBUG_TI, "Could not download the update", it) gtfsRtRequestRunning.postValue(false) if(it is GtfsRtPositionsRequest.RequestError){ val status = when(it.result) { Fetcher.Result.OK -> LivePositionsServiceStatus.OK Fetcher.Result.PARSER_ERROR -> LivePositionsServiceStatus.ERROR_PARSING_RESPONSE Fetcher.Result.SERVER_ERROR_404 -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.SERVER_ERROR -> LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE Fetcher.Result.CONNECTION_ERROR -> LivePositionsServiceStatus.ERROR_CONNECTION else -> LivePositionsServiceStatus.ERROR_CONNECTION } serviceStatus.postValue(status) } else serviceStatus.postValue(LivePositionsServiceStatus.ERROR_NETWORK_RESPONSE) } fun requestGTFSUpdates(){ if(gtfsRtRequestRunning.value == null || !gtfsRtRequestRunning.value!!) { val request = GtfsRtPositionsRequest(positionRequestErrorListener, gtfsPositionsReqListener) request.setRetryPolicy( DefaultRetryPolicy(1000,10,DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) ) netVolleyManager.requestQueue.add(request) Log.i(DEBUG_TI, "Requested GTFS realtime position updates") gtfsRtRequestRunning.value = true } } fun requestDelayedGTFSUpdates(timems: Long){ viewModelScope.launch { delay(timems) requestGTFSUpdates() } } override fun onCleared() { //stop the MQTT Service Log.d(DEBUG_TI, "Clearing the live positions view model, stopping the mqttClient") mqttClient.disconnect() super.onCleared() } //Request trips download fun downloadTripsFromMato(trips: List): Boolean{ if(trips.isEmpty()) return false var shouldContinue = false val currentDateTime = Date().time for (tr in trips){ if (!lastFailedTripsRequest.containsKey(tr)){ shouldContinue = true break } else{ //Log.i(DEBUG_TI, "Last time the trip has failed is ${lastFailedTripsRequest[tr]}") if ((lastFailedTripsRequest[tr]!!.time - currentDateTime) > MAX_TIME_RETRY){ shouldContinue =true break } } } if (shouldContinue) { //if one trip val workRequ =MatoTripsDownloadWorker.requestMatoTripsDownload(trips, getApplication(), "BusTO-MatoTripsDown") workRequ?.let { req -> Log.d(DEBUG_TI, "Enqueueing new work, saving work info") lastRequestedDownloadTrips.postValue(trips) //isLastWorkResultGood = } } else{ Log.w(DEBUG_TI, "Requested to fetch data for ${trips.size} trips but they all have failed before in the last $MAX_MINUTES_RETRY mins") } return shouldContinue } companion object{ private const val DEBUG_TI = "BusTO-LivePosViewModel" 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,107 +1,108 @@ -