diff --git a/app/build.gradle b/app/build.gradle index c6ad3d9..03ec0d7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,150 +1,153 @@ apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android' apply plugin: 'com.android.application' android { - compileSdk 34 + compileSdk 35 namespace "it.reyboz.bustorino" defaultConfig { applicationId "it.reyboz.bustorino" minSdkVersion 21 - targetSdkVersion 34 - buildToolsVersion = '34.0.0' + targetSdkVersion 35 + buildToolsVersion = '35.0.1' versionCode 62 versionName "2.3.1" vectorDrawables.useSupportLibrary = true multiDexEnabled true javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/assets/schemas/".toString()] } } testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } testOptions { unitTests.returnDefaultValues = true } sourceSets { androidTest.assets.srcDirs += files("$projectDir/assets/schemas/".toString()) } buildTypes { debug { applicationIdSuffix ".debug" versionNameSuffix "-dev" } gitpull{ applicationIdSuffix ".gitdev" versionNameSuffix "-gitdev" } } repositories { mavenCentral() mavenLocal() } dependencies { //new libraries } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlin { jvmToolchain 17 } lint { abortOnError false } androidResources { generateLocaleConfig true } + + buildFeatures{ + buildConfig = true + } } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Guava implementation for DBUpdateWorker implementation 'com.google.guava:guava:29.0-android' implementation "androidx.fragment:fragment-ktx:$fragment_version" implementation "androidx.activity:activity:$activity_version" implementation "androidx.annotation:annotation:1.6.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.appcompat:appcompat:$appcompat_version" implementation "androidx.appcompat:appcompat-resources:$appcompat_version" implementation "androidx.preference:preference:$preference_version" implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "com.google.android.material:material:1.11.0" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" implementation 'org.jsoup:jsoup:1.15.3' implementation 'com.readystatesoftware.sqliteasset:sqliteassethelper:2.0.1' implementation 'com.android.volley:volley:1.2.1' //maplibre implementation 'org.maplibre.gl:android-sdk:11.8.6' implementation 'org.maplibre.gl:android-sdk-turf:6.0.1' implementation 'org.maplibre.gl:android-plugin-annotation-v9:3.0.2' // remember to enable maven repo jitpack.io when wanting to use osmbonuspack //implementation 'com.github.MKergall:osmbonuspack:6.9.0' // ACRA implementation "ch.acra:acra-mail:$acra_version" implementation "ch.acra:acra-dialog:$acra_version" // google transit realtime implementation 'com.google.protobuf:protobuf-java:3.19.6' // mqtt library implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5' - implementation 'com.github.fabmazz:paho.mqtt.android:v1.0.0' - + implementation 'com.github.hannesa2:paho.mqtt.android:4.4' // ViewModel implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" // Lifecycles only (without ViewModel or LiveData) implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" // Legacy implementation 'androidx.legacy:legacy-support-v4:1.0.0' // Room components implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" //multidex - we need this to build the app implementation "androidx.multidex:multidex:$multidex_version" implementation 'de.siegmar:fastcsv:2.2.2' testImplementation 'junit:junit:4.12' implementation 'junit:junit:4.12' implementation "androidx.test.ext:junit:1.1.5" implementation "androidx.test:core:$androidXTestVersion" implementation "androidx.test:runner:$androidXTestVersion" implementation "androidx.room:room-testing:$room_version" androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test:core:$androidXTestVersion" androidTestImplementation "androidx.test:runner:$androidXTestVersion" androidTestImplementation "androidx.test:rules:$androidXTestVersion" androidTestImplementation "androidx.room:room-testing:$room_version" } diff --git a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java index 5a2b518..fe7b251 100644 --- a/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivityPrincipal.java @@ -1,809 +1,814 @@ /* 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.Build; import android.os.Bundle; import android.util.Log; import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.FrameLayout; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.GravityCompat; +import androidx.core.view.ViewCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.preference.PreferenceManager; import androidx.work.WorkInfo; import androidx.work.WorkManager; import com.google.android.material.navigation.NavigationView; import com.google.android.material.snackbar.Snackbar; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.backend.utils; import it.reyboz.bustorino.data.DBUpdateWorker; import it.reyboz.bustorino.data.DatabaseUpdate; 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 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; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d(DEBUG_TAG, "onCreate, savedInstanceState is: "+savedInstanceState); setContentView(R.layout.activity_principal); + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + getWindow().setNavigationBarContrastEnforced(false); + } + */ boolean showingArrivalsFromIntent = false; 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 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; DatabaseUpdate.requestDBUpdateWithWork(this, true, true); } PreferencesHolder.setGtfsDBVersion(theShPr, db_version); } //Try (hopefully) database update if(!dataUpdateRequested) DatabaseUpdate.requestDBUpdateWithWork(this, false, false); /* Watch for database update */ final WorkManager workManager = WorkManager.getInstance(this); workManager.getWorkInfosForUniqueWorkLiveData(DBUpdateWorker.DEBUG_TAG) .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 setDefaultSettingsValuesWhenMissing(); //check if first run activity (IntroActivity) has been started once or not boolean hasIntroRun = theShPr.getBoolean(PreferencesHolder.PREF_INTRO_ACTIVITY_RUN,false); if(!hasIntroRun){ startIntroductionActivity(); } } 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); } /** * 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() { boolean foundFragment = false; 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 super.onBackPressed(); } /** * Create and show the SnackBar with the message */ 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 showLineOnMap(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("LineonMap-"+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; } } /** * Adjust setting to match the default ones */ private void setDefaultSettingsValuesWhenMissing(){ SharedPreferences mainSharedPref = PreferenceManager.getDefaultSharedPreferences(this); SharedPreferences.Editor editor = mainSharedPref.edit(); //Main fragment to show String screen = mainSharedPref.getString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, ""); boolean edit = false; if (screen.isEmpty()){ editor.putString(SettingsFragment.PREF_KEY_STARTUP_SCREEN, "arrivals"); edit=true; } //Fetchers final Set setSelected = mainSharedPref.getStringSet(SettingsFragment.KEY_ARRIVALS_FETCHERS_USE, new HashSet<>()); if (setSelected.isEmpty()){ String[] defaultVals = getResources().getStringArray(R.array.arrivals_sources_values_default); editor.putStringSet(SettingsFragment.KEY_ARRIVALS_FETCHERS_USE, utils.convertArrayToSet(defaultVals)); edit=true; } //Live bus positions final String keySourcePositions=getString(R.string.pref_positions_source); final String positionsSource = mainSharedPref.getString(keySourcePositions, ""); if(positionsSource.isEmpty()){ String[] defaultVals = getResources().getStringArray(R.array.positions_source_values); editor.putString(keySourcePositions, defaultVals[0]); edit=true; } //Map style final String mapStylePref = mainSharedPref.getString(SettingsFragment.LIBREMAP_STYLE_PREF_KEY, ""); if(mapStylePref.isEmpty()){ final String[] defaultVals = getResources().getStringArray(R.array.map_style_pref_values); editor.putString(SettingsFragment.LIBREMAP_STYLE_PREF_KEY, defaultVals[0]); edit=true; } if (edit){ editor.commit(); } } } diff --git a/app/src/main/java/it/reyboz/bustorino/ActivitySettings.java b/app/src/main/java/it/reyboz/bustorino/ActivitySettings.java index 1731196..6140df5 100644 --- a/app/src/main/java/it/reyboz/bustorino/ActivitySettings.java +++ b/app/src/main/java/it/reyboz/bustorino/ActivitySettings.java @@ -1,37 +1,39 @@ package it.reyboz.bustorino; import android.os.Bundle; +import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import android.util.Log; import it.reyboz.bustorino.fragments.SettingsFragment; public class ActivitySettings extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); - + Toolbar mToolbar = findViewById(R.id.default_toolbar); + setSupportActionBar(mToolbar); ActionBar ab = getSupportActionBar(); if(ab!=null) { - ab.setIcon(R.drawable.ic_launcher); + //ab.setIcon(R.drawable.ic_launcher); ab.setDisplayHomeAsUpEnabled(true); } else { Log.e("SETTINGS_ACTIV","ACTION BAR IS NULL"); } FragmentManager framan = getSupportFragmentManager(); FragmentTransaction ft = framan.beginTransaction(); ft.replace(R.id.setting_container,new SettingsFragment()); ft.commit(); } } /** * Interesting thing * Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG) * .setAction("Action", null).show(); */ 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 b6182fd..2b00d9d 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,407 +1,411 @@ package it.reyboz.bustorino.backend.mato import android.app.Notification import android.app.NotificationManager import android.content.Context import android.os.Build import android.util.Log import androidx.lifecycle.LifecycleOwner import info.mqtt.android.service.Ack import info.mqtt.android.service.MqttAndroidClient import info.mqtt.android.service.QoS import it.reyboz.bustorino.backend.Notifications import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import org.eclipse.paho.client.mqttv3.* import org.json.JSONArray import org.json.JSONException import java.lang.ref.WeakReference import java.util.* typealias PositionsMap = HashMap > class MQTTMatoClient(): MqttCallbackExtended{ private var isStarted = false private var subscribedToAll = false private var client: MqttAndroidClient? = null //private var clientID = "" private val respondersMap = HashMap>>() private val currentPositions = PositionsMap() 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 lateinit var notification: Notification private fun connect(context: Context, iMqttActionListener: IMqttActionListener?){ val clientID = "mqtt-explorer-${getRandomString(8)}"//"mqttjs_${getRandomString(8)}" //notification = Notifications.makeMQTTServiceNotification(context) client = MqttAndroidClient(context,SERVER_ADDR,clientID,Ack.AUTO_ACK) // WE DO NOT WANT A FOREGROUND SERVICE -> it's only more mayhem // (and the positions need to be downloaded only when the app is shown) // update, 2024-04: Google Play doesn't understand our needs, so we put back the notification // and add a video of it working as Google wants /*if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ //we need a notification Notifications.createLivePositionsChannel(context) val notific = Notifications.makeMQTTServiceNotification(context) client!!.setForegroundService(notific) notification=notific }*/ val options = MqttConnectOptions() //options.sslProperties = options.isCleanSession = true val headersPars = Properties() headersPars.setProperty("Origin","https://mato.muoversiatorino.it") headersPars.setProperty("Host","mapi.5t.torino.it") options.customWebSocketHeaders = headersPars Log.d(DEBUG_TAG,"client name: $clientID") //actually connect isStarted = true client!!.connect(options,null, iMqttActionListener) client!!.setCallback(this) if (this.context ==null) this.context = context.applicationContext } 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 connectTopic(topic: String){ if(context==null){ Log.e(DEBUG_TAG, "Trying to connect but context is null") return } connectionTrials += 1 connect(context!!, object : IMqttActionListener{ override fun onSuccess(asyncActionToken: IMqttToken?) { val disconnectedBufferOptions = DisconnectedBufferOptions() disconnectedBufferOptions.isBufferEnabled = true disconnectedBufferOptions.bufferSize = 100 disconnectedBufferOptions.isPersistBuffer = false disconnectedBufferOptions.isDeleteOldestMessages = false client!!.setBufferOpts(disconnectedBufferOptions) client!!.subscribe(topic, QoS.AtMostOnce.value) isStarted = true } override fun onFailure(asyncActionToken: IMqttToken?, exception: Throwable?) { Log.e(DEBUG_TAG, "FAILED To connect to the server",exception) if (connectionTrials < 10) { Log.d(DEBUG_TAG, "Reconnecting") connectTopic(topic) } else { //reset connection trials connectionTrials = 0 } } }) } fun startAndSubscribe(lineId: String, responder: MQTTMatoListener, context: Context): Boolean{ //start the client, and then subscribe to the topic val topic = mapTopic(lineId) this.context = context.applicationContext synchronized(this) { if(!isStarted){ connectTopic(topic) //wait for connection } else { client!!.subscribe(topic, QoS.AtMostOnce.value) } } synchronized(this){ if (!respondersMap.contains(lineId)) respondersMap[lineId] = ArrayList() respondersMap[lineId]!!.add(WeakReference(responder)) Log.d(DEBUG_TAG, "Add MQTT Listener for line $lineId, topic $topic") } return true } fun stopMatoRequests(responder: MQTTMatoListener){ var removed = false - for ((line,v)in respondersMap.entries){ + for ((linekey,responderList)in respondersMap.entries){ var done = false - for (el in v){ + for (el in responderList){ if (el.get()==null){ - v.remove(el) + responderList.remove(el) } else if(el.get() == responder){ - v.remove(el) + responderList.remove(el) done = true } if (done) break } - if(done) Log.d(DEBUG_TAG, "Removed one listener for line $line, listeners: $v") + if(done) Log.d(DEBUG_TAG, "Removed one listener for line $linekey, listeners: $responderList") //if (done) break - if (v.isEmpty()){ + if (responderList.isEmpty()){ //actually unsubscribe - client?.unsubscribe( mapTopic(line)) + try { + client?.unsubscribe(mapTopic(linekey)) + } catch (e: Exception){ + Log.e(DEBUG_TAG, "Tried unsubscribing but there was an error in the client library:\n$e") + } } removed = done || removed } // check responders map, remove lines that have no responders for(line in respondersMap.keys){ if(respondersMap[line]?.isEmpty() == true){ respondersMap.remove(line) } } Log.d(DEBUG_TAG, "Removed: $removed, respondersMap: $respondersMap") } fun getPositions(): PositionsMap{ return currentPositions } /** * 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 { wrD.get()!!.onUpdateReceived(currentPositions) //sent = true count++ } } return count } 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") } }) } } override fun messageArrived(topic: String?, message: MqttMessage?) { if (topic==null || message==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: MqttMessage){ val vals = topic.split("/") val lineId = vals[1] val vehicleId = vals[2] val timestamp = (System.currentTimeMillis() / 1000 ) as Long val messString = String(message.payload) try { val jsonList = JSONArray(messString) /*val posUpdate = MQTTPositionUpdate(lineId+"U", vehicleId, jsonList.getDouble(0), jsonList.getDouble(1), if(jsonList.get(2).equals(null)) null else jsonList.getInt(2), if(jsonList.get(3).equals(null)) null else jsonList.getInt(3), if(jsonList.get(4).equals(null)) null else jsonList.getString(4)+"U", if(jsonList.get(5).equals(null)) null else jsonList.getInt(5), if(jsonList.get(6).equals(null)) null else jsonList.getInt(6), //full ) */ if(jsonList.get(4)==null){ Log.d(DEBUG_TAG, "We have null tripId: line $lineId veh $vehicleId: $jsonList") 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 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)") } catch (e: Exception){ Log.e(DEBUG_TAG, "Exception occurred", e) } } override fun deliveryComplete(token: IMqttDeliveryToken?) { //NOT USED (we're not sending any messages) } /*/** * 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="wss://mapi.5t.torino.it:443/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("") } fun interface MQTTMatoListener{ //positionsMap is a dict with line -> vehicle -> Update fun onUpdateReceived(posUpdates: PositionsMap) } } } 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/MatoPatternsDownloadWorker.kt b/app/src/main/java/it/reyboz/bustorino/data/MatoPatternsDownloadWorker.kt index e263464..c6edb5d 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/MatoPatternsDownloadWorker.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/MatoPatternsDownloadWorker.kt @@ -1,101 +1,101 @@ /* BusTO - Data 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.data import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.work.* import it.reyboz.bustorino.backend.Fetcher import it.reyboz.bustorino.backend.Notifications import it.reyboz.bustorino.backend.mato.MatoAPIFetcher import java.util.concurrent.atomic.AtomicReference class MatoPatternsDownloadWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { val routesList = inputData.getStringArray(ROUTES_KEYS) if (routesList== null){ Log.e(DEBUG_TAG,"routes list given is null") return Result.failure() } val res = AtomicReference(Fetcher.Result.OK) val gtfsRepository = GtfsRepository(applicationContext) val patterns = MatoAPIFetcher.getPatternsWithStops(applicationContext, routesList.asList().toMutableList(), res) if (res.get() != Fetcher.Result.OK) { Log.e(DatabaseUpdate.DEBUG_TAG, "Something went wrong downloading patterns") return Result.failure() } gtfsRepository.gtfsDao.insertPatterns(patterns) //Insert the PatternStops gtfsRepository.gtfsDao.insertPatternStops(DatabaseUpdate.makeStopsForPatterns(patterns)) return Result.success() } override suspend fun getForegroundInfo(): ForegroundInfo { val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val context = applicationContext Notifications.createDBNotificationChannel(context) return ForegroundInfo(NOTIFICATION_ID, Notifications.makeMatoDownloadNotification(context)) } companion object{ const val ROUTES_KEYS = "routesToDownload" const val DEBUG_TAG="BusTO:MatoPattrnDownWRK" const val NOTIFICATION_ID=21983102 const val TAG_PATTERNS ="matoPatternsDownload" fun downloadPatternsForRoutes(routesIds: List, context: Context): Boolean{ if(routesIds.isEmpty()) return false; val workManager = WorkManager.getInstance(context); val info = workManager.getWorkInfosForUniqueWork(TAG_PATTERNS).get() val runNewWork = if(info.isEmpty()) true else info[0].state!= WorkInfo.State.RUNNING && info[0].state!= WorkInfo.State.ENQUEUED val addDat = if(info.isEmpty()) null else info[0].state Log.d(DEBUG_TAG, "Request to download and insert patterns for ${routesIds.size} routes, proceed: $runNewWork, workstate: $addDat") if(runNewWork){ - val routeIdsArray = routesIds.toTypedArray() + val routeIdsArray: Array = routesIds.toTypedArray() val dataBuilder = Data.Builder().putStringArray(ROUTES_KEYS,routeIdsArray) val requ = OneTimeWorkRequest.Builder(MatoPatternsDownloadWorker::class.java) .setInputData(dataBuilder.build()).setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .addTag(TAG_PATTERNS) .build() workManager.enqueueUniqueWork(TAG_PATTERNS, ExistingWorkPolicy.KEEP, requ) } return true } } } diff --git a/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt b/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt index ba37750..1b96cfd 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt @@ -1,140 +1,140 @@ /* BusTO - Data 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.data import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.work.* import it.reyboz.bustorino.backend.Notifications import it.reyboz.bustorino.data.gtfs.GtfsTrip import java.util.concurrent.CountDownLatch class MatoTripsDownloadWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { return downloadGtfsTrips() } /** * Download GTFS Trips from Mato */ private fun downloadGtfsTrips():Result{ val tripsList = inputData.getStringArray(TRIPS_KEYS) if (tripsList== null){ Log.e(DEBUG_TAG,"trips list given is null") return Result.failure() } val gtfsRepository = GtfsRepository(applicationContext) val matoRepository = MatoRepository(applicationContext) //clear the matoTrips val queriedMatoTrips = HashSet() val downloadedMatoTrips = ArrayList() val failedMatoTripsDownload = HashSet() Log.i(DEBUG_TAG, "Requesting download for the trips") val requestCountDown = CountDownLatch(tripsList.size); for(trip in tripsList){ queriedMatoTrips.add(trip) matoRepository.requestTripUpdate(trip,{error-> Log.e(DEBUG_TAG, "Cannot download Gtfs Trip $trip, error: $error") //val stacktrace = error.stackTrace.take(5) //Log.w(DEBUG_TAG, "Stacktrace:\n$stacktrace") failedMatoTripsDownload.add(trip) requestCountDown.countDown() }){ if(it.isSuccess){ if (it.result == null){ Log.e(DEBUG_TAG, "Got null result"); } downloadedMatoTrips.add(it.result!!) } else{ failedMatoTripsDownload.add(trip) } Log.i( DEBUG_TAG,"Result download, so far, trips: ${queriedMatoTrips.size}, failed: ${failedMatoTripsDownload.size}," + " succeded: ${downloadedMatoTrips.size}") //check if we can insert the trips requestCountDown.countDown() } } requestCountDown.await() val tripsIDsCompleted = downloadedMatoTrips.map { trip-> trip.tripID } if (tripsIDsCompleted.isEmpty()){ Log.d(DEBUG_TAG, "No trips have been downloaded, set work to fail") return Result.failure() } else { val doInsert = (queriedMatoTrips subtract failedMatoTripsDownload).containsAll(tripsIDsCompleted) Log.i(DEBUG_TAG, "Inserting missing GtfsTrips in the database, should insert $doInsert") if (doInsert) { gtfsRepository.gtfsDao.insertTrips(downloadedMatoTrips) } return Result.success() } } override suspend fun getForegroundInfo(): ForegroundInfo { val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val context = applicationContext Notifications.createDBNotificationChannel(context) return ForegroundInfo(NOTIFICATION_ID, Notifications.makeMatoDownloadNotification(context)) } companion object{ const val TRIPS_KEYS = "tripsToDownload" const val DEBUG_TAG="BusTO:MatoTripDownWRK" const val NOTIFICATION_ID=42424221 const val TAG_TRIPS ="gtfsTripsDownload" fun requestMatoTripsDownload(trips: List, context: Context, debugTag: String): OneTimeWorkRequest? { if (trips.isEmpty()) return null val workManager = WorkManager.getInstance(context) val info = workManager.getWorkInfosForUniqueWork(TAG_TRIPS).get() val runNewWork = if(info.isEmpty()) true else info[0].state!= WorkInfo.State.RUNNING && info[0].state!= WorkInfo.State.ENQUEUED val addDat = if(info.isEmpty()) null else info[0].state Log.d(debugTag, "Request to download and insert ${trips.size} trips, proceed: $runNewWork, workstate: $addDat") if(runNewWork) { - val tripsArr = trips.toTypedArray() + val tripsArr: Array = trips.toTypedArray() val dataBuilder = Data.Builder().putStringArray(TRIPS_KEYS, tripsArr) //build() val requ = OneTimeWorkRequest.Builder(MatoTripsDownloadWorker::class.java) .setInputData(dataBuilder.build()).setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .addTag(TAG_TRIPS) .build() workManager.enqueueUniqueWork(TAG_TRIPS, ExistingWorkPolicy.KEEP, requ) return requ } else return null; } } } diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt index 4104d0f..8c151e2 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/GeneralMapLibreFragment.kt @@ -1,171 +1,184 @@ package it.reyboz.bustorino.fragments 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 androidx.preference.PreferenceManager import com.google.gson.JsonObject import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.PreferencesHolder import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraPosition import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.maps.OnMapReadyCallback import org.maplibre.android.maps.Style import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.Point abstract class GeneralMapLibreFragment: ScreenBaseFragment(), OnMapReadyCallback { protected var map: MapLibreMap? = null protected var shownStopInBottomSheet : Stop? = null protected var savedMapStateOnPause : Bundle? = null // Declare a variable for MapView protected lateinit var mapView: MapView protected lateinit var mapStyle: Style protected lateinit var stopsSource: GeoJsonSource protected lateinit var busesSource: GeoJsonSource protected lateinit var selectedStopSource: GeoJsonSource protected lateinit var sharedPreferences: SharedPreferences private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener(){ pref, key -> /*when(key){ SettingsFragment.LIBREMAP_STYLE_PREF_KEY -> reloadMap() } */ if(key == SettingsFragment.LIBREMAP_STYLE_PREF_KEY){ Log.d(DEBUG_TAG,"ASKING RELOAD OF MAP") reloadMap() } } private var lastMapStyle ="" override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) //init map MapLibre.getInstance(requireContext()) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { lastMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onCreateView lastMapStyle: $lastMapStyle") return super.onCreateView(inflater, container, savedInstanceState) } override fun onResume() { super.onResume() val newMapStyle = PreferencesHolder.getMapLibreStyleFile(requireContext()) Log.d(DEBUG_TAG, "onResume newMapStyle: $newMapStyle, lastMapStyle: $lastMapStyle") if(newMapStyle!=lastMapStyle){ reloadMap() } } + @Deprecated("Deprecated in Java") override fun onLowMemory() { super.onLowMemory() mapView.onLowMemory() } protected fun reloadMap(){ /*map?.let { Log.d("GeneralMapFragment", "RELOADING MAP") //save map state savedMapStateOnPause = saveMapStateInBundle() onMapDestroy() //Destroy and recreate MAP mapView.onDestroy() mapView.onCreate(null) mapView.getMapAsync(this) } */ //TODO figure out how to switch map safely } abstract fun openStopInBottomSheet(stop: Stop) //For extra stuff to do when the map is destroyed abstract fun onMapDestroy() - protected fun restoreMapStateFromBundle(bundle: Bundle){ + protected fun restoreMapStateFromBundle(bundle: Bundle): Boolean{ val nullDouble = -10_000.0 - val latCenter = bundle.getDouble("center_map_lat", -10.0) - val lonCenter = bundle.getDouble("center_map_lon",-10.0) - val zoom = bundle.getDouble("map_zoom", -10.0) + var boundsRestored =false + val latCenter = bundle.getDouble("center_map_lat", nullDouble) + val lonCenter = bundle.getDouble("center_map_lon",nullDouble) + val zoom = bundle.getDouble("map_zoom", nullDouble) val bearing = bundle.getDouble("map_bearing", nullDouble) val tilt = bundle.getDouble("map_tilt", nullDouble) - if(lonCenter>=0 &&latCenter>=0) map?.let { - val newPos = CameraPosition.Builder().target(LatLng(latCenter,lonCenter)) + if(lonCenter!=nullDouble &&latCenter!=nullDouble) map?.let { + val center = LatLng(latCenter, lonCenter) + val newPos = CameraPosition.Builder().target(center) if(zoom>0) newPos.zoom(zoom) if(bearing!=nullDouble) newPos.bearing(bearing) if(tilt != nullDouble) newPos.tilt(tilt) it.cameraPosition=newPos.build() + Log.d(DEBUG_TAG, "Restored map state from Bundle, center: $center, zoom: $zoom, bearing $bearing, tilt $tilt") + boundsRestored =true + } else{ + Log.d(DEBUG_TAG, "Not restoring map state, center: $latCenter,$lonCenter; zoom: $zoom, bearing: $bearing, tilt $tilt") } val mStop = bundle.getBundle("shown_stop")?.let { Stop.fromBundle(it) } mStop?.let { openStopInBottomSheet(it) } + return boundsRestored } protected fun saveMapStateBeforePause(bundle: Bundle){ map?.let { val newBbox = it.projection.visibleRegion.latLngBounds val cp = it.cameraPosition bundle.putDouble("center_map_lat", newBbox.center.latitude) bundle.putDouble("center_map_lon", newBbox.center.longitude) it.cameraPosition.zoom.let { z-> bundle.putDouble("map_zoom",z) } bundle.putDouble("map_bearing",cp.bearing) bundle.putDouble("map_tilt", cp.tilt) + + val locationComponent = it.locationComponent + bundle.putBoolean(KEY_LOCATION_ENABLED,locationComponent.isLocationComponentEnabled) + bundle.putParcelable("last_location", locationComponent.lastKnownLocation) } shownStopInBottomSheet?.let { bundle.putBundle("shown_stop", it.toBundle()) } } protected fun saveMapStateInBundle(): Bundle { val b = Bundle() saveMapStateBeforePause(b) return b } protected fun stopToGeoJsonFeature(s: Stop): Feature{ return Feature.fromGeometry( Point.fromLngLat(s.longitude!!, s.latitude!!), JsonObject().apply { addProperty("id", s.ID) addProperty("name", s.stopDefaultName) //addProperty("routes", s.routesThatStopHereToString()) // Add routes array to JSON object } ) } companion object{ private const val DEBUG_TAG="GeneralMapLibreFragment" const val BUSES_SOURCE_ID = "buses-source" const val BUSES_LAYER_ID = "buses-layer" const val SEL_STOP_SOURCE="selected-stop-source" const val SEL_STOP_LAYER = "selected-stop-layer" + + const val KEY_LOCATION_ENABLED="location_enabled" } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt index dbc0444..bfc0946 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapLibreFragment.kt @@ -1,1141 +1,1178 @@ package it.reyboz.bustorino.fragments import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Color import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.LinearInterpolator import android.widget.ImageButton import android.widget.RelativeLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.preference.PreferenceManager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.gson.JsonObject import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.fragments.SettingsFragment.LIVE_POSITIONS_PREF_MQTT_VALUE import it.reyboz.bustorino.map.MapLibreUtils import it.reyboz.bustorino.map.MapLibreStyles import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.util.ViewUtils import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import it.reyboz.bustorino.viewmodels.StopsMapViewModel 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.location.LocationComponent import org.maplibre.android.location.LocationComponentOptions import org.maplibre.android.location.modes.CameraMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.plugins.annotation.Symbol import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.layers.Property.* 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.Point // TODO: Rename parameter arguments, choose names that match // the fragment initialization parameters, e.g. ARG_ITEM_NUMBER private const val STOP_TO_SHOW = "stoptoshow" /** * A simple [Fragment] subclass. * Use the [MapLibreFragment.newInstance] factory method to * create an instance of this fragment. */ class MapLibreFragment : GeneralMapLibreFragment() { protected var fragmentListener: CommonFragmentListener? = null private lateinit var locationComponent: LocationComponent private var lastLocation: Location? = null private val stopsViewModel: StopsMapViewModel by viewModels() private var stopsShowing = ArrayList(0) private var isBottomSheetShowing = false //private lateinit var symbolManager: SymbolManager // Sources for stops and buses are in GeneralMapLibreFragment - + private var isUserMovingCamera = false private var stopsLayerStarted = false private var lastStopsSizeShown = 0 private var lastBBox = LatLngBounds.from(2.0, 2.0, 1.0,1.0) private var mapInitCompleted =false private var stopsRedrawnTimes = 0 //bottom Sheet behavior private lateinit var bottomSheetBehavior: BottomSheetBehavior private var bottomLayout: RelativeLayout? = null private lateinit var stopTitleTextView: TextView private lateinit var stopNumberTextView: TextView private lateinit var linesPassingTextView: TextView private lateinit var arrivalsCard: CardView private lateinit var directionsCard: CardView //private var stopActiveSymbol: Symbol? = null // Location stuff private lateinit var locationManager: LocationManager private lateinit var showUserPositionButton: ImageButton private lateinit var centerUserButton: ImageButton private lateinit var followUserButton: ImageButton private var followingUserLocation = false private var pendingLocationActivation = false private var ignoreCameraMovementForFollowing = true private var enablingPositionFromClick = false - private val positionRequestLauncher = - registerForActivityResult, Map>( - ActivityResultContracts.RequestMultiplePermissions(), - ActivityResultCallback { result -> + private val positionRequestLauncher = registerForActivityResult, Map>( + ActivityResultContracts.RequestMultiplePermissions(), ActivityResultCallback { result -> if (result == null) { Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?") + }else if(!pendingLocationActivation){ + /// SHOULD DO NOTHING HERE + Log.d(DEBUG_TAG, "Requested location but now there is no pendingLocationActivation") } 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 Log.d(DEBUG_TAG, "HAVE THE PERMISSIONS") if (context == null || requireContext().getSystemService(Context.LOCATION_SERVICE) == null) return@ActivityResultCallback ///@registerForActivityResult - val locationManager = - requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager - @SuppressLint("MissingPermission") val userLocation = - locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) - if (userLocation != null) { - if(LatLng(userLocation.latitude, userLocation.longitude).distanceTo(DEFAULT_LATLNG) >= MAX_DIST_KM*1000){ + val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager + var lastLoc = stopsViewModel.lastUserLocation + @SuppressLint("MissingPermission") + if(lastLoc==null) lastLoc = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + else Log.d(DEBUG_TAG, "Got last location from cache") + + if (lastLoc != null) { + if(LatLng(lastLoc.latitude, lastLoc.longitude).distanceTo(DEFAULT_LATLNG) <= MAX_DIST_KM*1000){ + Log.d(DEBUG_TAG, "Showing the user position") setMapLocationEnabled(true, true, false) + } else{ + setMapLocationEnabled(false, false,false) + context?.let{Toast.makeText(it,R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show()} } } else requestInitialUserLocation() } else{ - Toast.makeText(requireContext(),"User location disabled", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(),R.string.location_disabled, Toast.LENGTH_SHORT).show() Log.w(DEBUG_TAG, "No location permission") } }) private val showUserPositionRequestLauncher = registerForActivityResult, Map>( 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 setMapLocationEnabled(true, true, enablingPositionFromClick) } else Log.w(DEBUG_TAG, "No location permission") }) //BUS POSITIONS private var useMQTTViewModel = true private val livePositionsViewModel : LivePositionsViewModel by viewModels() private val positionsByVehDict = HashMap(5) private val animatorsByVeh = HashMap() private var lastUpdateTime : Long = -1 //private var busLabelSymbolsByVeh = HashMap() private val symbolsToUpdate = ArrayList() private var initialStopToShow : Stop? = null private var initialStopShown = false //shown stuff //private var savedStateOnStop : Bundle? = null private val showBusLayer = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { initialStopToShow = Stop.fromBundle(arguments) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // Inflate the layout for this fragment val rootView = inflater.inflate(R.layout.fragment_map_libre, container, false) //reset the counter lastStopsSizeShown = 0 stopsRedrawnTimes = 0 stopsLayerStarted = false symbolsToUpdate.clear() // Init layout view // Init the MapView mapView = rootView.findViewById(R.id.libreMapView) val restoreBundle = stopsViewModel.savedState if(restoreBundle!=null){ mapView.onCreate(restoreBundle) } else mapView.onCreate(savedInstanceState) mapView.getMapAsync(this) //{ //map -> //map.setStyle("https://demotiles.maplibre.org/style.json") } //init bottom sheet val bottomSheet = rootView.findViewById(R.id.bottom_sheet) bottomLayout = bottomSheet stopTitleTextView = bottomSheet.findViewById(R.id.stopTitleTextView) stopNumberTextView = bottomSheet.findViewById(R.id.stopNumberTextView) linesPassingTextView = bottomSheet.findViewById(R.id.linesPassingTextView) arrivalsCard = bottomSheet.findViewById(R.id.arrivalsCardButton) directionsCard = bottomSheet.findViewById(R.id.directionsCardButton) showUserPositionButton = rootView.findViewById(R.id.locationEnableIcon) showUserPositionButton.setOnClickListener(this::switchUserLocationStatus) followUserButton = rootView.findViewById(R.id.followUserImageButton) centerUserButton = rootView.findViewById(R.id.centerMapImageButton) bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN arrivalsCard.setOnClickListener { if(context!=null){ Toast.makeText(context,"ARRIVALS", Toast.LENGTH_SHORT).show() } } centerUserButton.setOnClickListener { if(context!=null && locationComponent.isLocationComponentEnabled) { val location = locationComponent.lastKnownLocation location?.let { mapView.getMapAsync { map -> map.animateCamera(CameraUpdateFactory.newCameraPosition( CameraPosition.Builder().target(LatLng(location.latitude, location.longitude)).build()), 500) } } } } followUserButton.setOnClickListener { // onClick user following button if(context!=null && locationComponent.isLocationComponentEnabled){ if(followingUserLocation) locationComponent.cameraMode = CameraMode.NONE else locationComponent.cameraMode = CameraMode.TRACKING // CameraMode.TRACKING makes the camera move and jump to the location setFollowingUser(!followingUserLocation) } } locationManager = requireActivity().getSystemService(Context.LOCATION_SERVICE) as LocationManager if (Permissions.bothLocationPermissionsGranted(requireContext())) { requestInitialUserLocation() } 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() } - positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) } // Setup close button rootView.findViewById(R.id.btnClose).setOnClickListener { hideStopBottomSheet() } Log.d(DEBUG_TAG, "Fragment View Created!") //TODO: Reshow last open stop when switching back to the map fragment return rootView } /** * This method sets up the map and the layers */ override fun onMapReady(mapReady: MapLibreMap) { this.map = mapReady 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 -> + val builder = Style.Builder().fromJson(mjson!!) - mapStyle = style - //setupLayers(style) + mapReady.setStyle(builder) { style -> - // Start observing data - observeStops() - initMapLocation(style, mapReady, requireContext()) - //init stop layer with this - val stopsInCache = stopsViewModel.getAllStopsLoaded() - if(stopsInCache.isEmpty()) - initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) - else - displayStops(stopsInCache) - if(showBusLayer) setupBusLayer(style) + mapStyle = style + //setupLayers(style) + // Start observing data + observeStops() + initMapLocation(style, mapReady, requireContext()) + //init stop layer with this + val stopsInCache = stopsViewModel.getAllStopsLoaded() + if(stopsInCache.isEmpty()) + initStopsLayer(style, FeatureCollection.fromFeatures(ArrayList())) + else + displayStops(stopsInCache) + if(showBusLayer) setupBusLayer(style) - /*symbolManager = SymbolManager(mapView,mapReady,style, null, "symbol-transit-airfield") - symbolManager.iconAllowOverlap = true - symbolManager.textAllowOverlap = false - symbolManager.textIgnorePlacement =true + /*symbolManager = SymbolManager(mapView,mapReady,style, null, "symbol-transit-airfield") + symbolManager.iconAllowOverlap = true + symbolManager.textAllowOverlap = false + symbolManager.textIgnorePlacement =true - */ - /*symbolManager.addClickListener{ _ -> - if (stopActiveSymbol!=null){ - hideStopBottomSheet() - - return@addClickListener true - } else - return@addClickListener false - } - */ + */ + /*symbolManager.addClickListener{ _ -> + if (stopActiveSymbol!=null){ + hideStopBottomSheet() + return@addClickListener true + } else + return@addClickListener false } - mapReady.addOnCameraIdleListener { - map?.let { - val newBbox = it.projection.visibleRegion.latLngBounds - if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ - //do nothing - } else { - stopsViewModel.loadStopsInLatLngBounds(newBbox) - lastBBox = newBbox + */ - } + } - } - } - mapReady.addOnCameraMoveStartedListener { + mapReady.addOnCameraIdleListener { + isUserMovingCamera = false + map?.let { + val newBbox = it.projection.visibleRegion.latLngBounds + if ((newBbox.center==lastBBox.center) && (newBbox.latitudeSpan==lastBBox.latitudeSpan) && (newBbox.longitudeSpan==lastBBox.latitudeSpan)){ + //do nothing + } else { + stopsViewModel.loadStopsInLatLngBounds(newBbox) + lastBBox = newBbox - map?.let { setFollowingUser(it.locationComponent.cameraMode == CameraMode.TRACKING) } - //setFollowingUser() + } } - mapReady.addOnMapClickListener { point -> - onMapClickReact(point) + } + mapReady.addOnCameraMoveStartedListener { v-> + if(v== MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE){ + //the user is moving the map + isUserMovingCamera = true } + map?.let { setFollowingUser(it.locationComponent.cameraMode == CameraMode.TRACKING) } + //setFollowingUser() - mapInitCompleted = true - // we start requesting the bus positions now - startRequestingPositions() } - savedMapStateOnPause?.let{ - restoreMapStateFromBundle(it) - pendingLocationActivation = false - Log.d(DEBUG_TAG, "Restored map state from the saved bundle") + + mapReady.addOnMapClickListener { point -> + onMapClickReact(point) } + + mapInitCompleted = true + // we start requesting the bus positions now + startRequestingPositions() + + //Restoring data + var boundsRestored = false + pendingLocationActivation = true + stopsViewModel.savedState?.let{ + boundsRestored = restoreMapStateFromBundle(it) + //why are we disabling it? + pendingLocationActivation = it.getBoolean(KEY_LOCATION_ENABLED,true) + Log.d(DEBUG_TAG, "Restored map state from the saved bundle: ") + } + if(pendingLocationActivation) + positionRequestLauncher.launch(Permissions.LOCATION_PERMISSIONS) + //reset saved State at the end - if( savedMapStateOnPause == null) { + if((!boundsRestored)) { //set initial position - val zoom = 15.0 //center position val latlngTarget = initialStopToShow?.let { LatLng(it.latitude!!, it.longitude!!) } ?: LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) - mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(zoom).build() + mapReady.cameraPosition = CameraPosition.Builder().target(latlngTarget).zoom(DEFAULT_ZOOM).build() } //reset saved state - savedMapStateOnPause = null + stopsViewModel.savedState = null } private fun onMapClickReact(point: LatLng): Boolean{ map?.let { mapReady -> val screenPoint = mapReady.projection.toScreenLocation(point) val features = mapReady.queryRenderedFeatures(screenPoint, STOPS_LAYER_ID) val busNearby = mapReady.queryRenderedFeatures(screenPoint, BUSES_LAYER_ID) if (features.isNotEmpty()) { val feature = features[0] val id = feature.getStringProperty("id") val name = feature.getStringProperty("name") //Toast.makeText(requireContext(), "Clicked on $name ($id)", Toast.LENGTH_SHORT).show() val stop = stopsViewModel.getStopByID(id) stop?.let { newstop -> val sameStopClicked = shownStopInBottomSheet?.let { newstop.ID==it.ID } ?: false if (isBottomSheetShowing) { hideStopBottomSheet() } if(!sameStopClicked){ openStopInBottomSheet(newstop) //isBottomSheetShowing = true //move camera if (newstop.latitude != null && newstop.longitude != null) //mapReady.cameraPosition = CameraPosition.Builder().target(LatLng(it.latitude!!, it.longitude!!)).build() mapReady.animateCamera( CameraUpdateFactory.newLatLng(LatLng(newstop.latitude!!, newstop.longitude!!)), 750 ) } } return true } else if (busNearby.isNotEmpty()) { val feature = busNearby[0] val vehid = feature.getStringProperty("veh") val route = feature.getStringProperty("line") Toast.makeText(context, "Veh $vehid on route $route", Toast.LENGTH_SHORT).show() return true } } return false } private fun initStopsLayer(style: Style, features:FeatureCollection){ stopsSource = GeoJsonSource(STOPS_SOURCE_ID,features) style.addSource(stopsSource) // add icon style.addImage(STOP_IMAGE_ID, ResourcesCompat.getDrawable(resources,R.drawable.bus_stop_new, activity?.theme)!!) style.addImage(STOP_ACTIVE_IMG, ResourcesCompat.getDrawable(resources, R.drawable.bus_stop_new_highlight, activity?.theme)!!) style.addImage("ball",ResourcesCompat.getDrawable(resources, R.drawable.ball, activity?.theme)!!) // Stops layer val stopsLayer = SymbolLayer(STOPS_LAYER_ID, STOPS_SOURCE_ID) stopsLayer.withProperties( PropertyFactory.iconImage(STOP_IMAGE_ID), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true) ) style.addLayerBelow(stopsLayer, "symbol-transit-airfield") //"label_country_1") this with OSM Bright selectedStopSource = GeoJsonSource(SEL_STOP_SOURCE, FeatureCollection.fromFeatures(ArrayList())) style.addSource(selectedStopSource) val selStopLayer = SymbolLayer(SEL_STOP_LAYER, SEL_STOP_SOURCE) selStopLayer.withProperties( PropertyFactory.iconImage(STOP_ACTIVE_IMG), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconAnchor(ICON_ANCHOR_CENTER), ) style.addLayerAbove(selStopLayer, STOPS_LAYER_ID) stopsLayerStarted = true } /** * Setup the Map Layers */ private fun setupBusLayer(style: Style) { // Buses source busesSource = GeoJsonSource(BUSES_SOURCE_ID) style.addSource(busesSource) style.addImage("bus_symbol",ResourcesCompat.getDrawable(resources, R.drawable.map_bus_position_icon, activity?.theme)!!) // Buses layer val busesLayer = SymbolLayer(BUSES_LAYER_ID, BUSES_SOURCE_ID).apply { withProperties( PropertyFactory.iconImage("bus_symbol"), PropertyFactory.iconSize(1.2f), PropertyFactory.iconAllowOverlap(true), PropertyFactory.iconIgnorePlacement(true), PropertyFactory.iconRotate(Expression.get("bearing")), PropertyFactory.iconRotationAlignment(ICON_ROTATION_ALIGNMENT_MAP), PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f), PropertyFactory.textFont(arrayOf("noto_sans_regular")) ) } style.addLayerAbove(busesLayer, STOPS_LAYER_ID) //Line names layer /*vehiclesLabelsSource = GeoJsonSource(LABELS_SOURCE) style.addSource(vehiclesLabelsSource) val textLayer = SymbolLayer(LABELS_LAYER_ID, LABELS_SOURCE).apply { withProperties( PropertyFactory.textField("label"), PropertyFactory.textSize(30f), //PropertyFactory.textHaloColor(Color.BLACK), //PropertyFactory.textHaloWidth(1f), PropertyFactory.textAnchor(TEXT_ANCHOR_CENTER), PropertyFactory.textAllowOverlap(true), PropertyFactory.textField(Expression.get("line")), PropertyFactory.textColor(Color.WHITE), PropertyFactory.textRotationAlignment(TEXT_ROTATION_ALIGNMENT_VIEWPORT), PropertyFactory.textSize(12f) ) } style.addLayerAbove(textLayer, BUSES_LAYER_ID) */ } /** * Update the bottom sheet with the stop information */ override fun openStopInBottomSheet(stop: Stop){ bottomLayout?.let { //lay.findViewById(R.id.stopTitleTextView).text ="${stop.ID} - ${stop.stopDefaultName}" val stopName = stop.stopUserName ?: stop.stopDefaultName stopTitleTextView.text = stopName//stop.stopDefaultName stopNumberTextView.text = stop.ID val string_show = if (stop.numRoutesStopping==0) "" else if (stop.numRoutesStopping <= 1) requireContext().getString(R.string.line_fill, stop.routesThatStopHereToString()) else requireContext().getString(R.string.lines_fill, stop.routesThatStopHereToString()) linesPassingTextView.text = string_show //SET ON CLICK LISTENER arrivalsCard.setOnClickListener{ fragmentListener?.requestArrivalsForStopID(stop.ID) } directionsCard.setOnClickListener { ViewUtils.openStopInOutsideApp(stop, context) } } //add stop marker if (stop.latitude!=null && stop.longitude!=null) { /*stopActiveSymbol = symbolManager.create( SymbolOptions() .withLatLng(LatLng(stop.latitude!!, stop.longitude!!)) .withIconImage(STOP_ACTIVE_IMG) .withIconAnchor(ICON_ANCHOR_CENTER) //.withTextFont(arrayOf("noto_sans_regular"))) */ Log.d(DEBUG_TAG, "Showing stop: ${stop.ID}") val list = ArrayList() list.add(stopToGeoJsonFeature(stop)) selectedStopSource.setGeoJson( FeatureCollection.fromFeatures(list) ) } shownStopInBottomSheet = stop bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED isBottomSheetShowing = true } override fun onAttach(context: Context) { super.onAttach(context) fragmentListener = if (context is CommonFragmentListener) { context } else { throw RuntimeException( context.toString() + " must implement FragmentListenerMain" ) } } override fun onDetach() { super.onDetach() fragmentListener = null } override fun onStart() { super.onStart() mapView.onStart() + + //restore state from viewModel + stopsViewModel.savedState?.let { + restoreMapStateFromBundle(it) + //reset state + stopsViewModel.savedState = null + } } override fun onResume() { super.onResume() mapView.onResume() val keySourcePositions = getString(R.string.pref_positions_source) if(showBusLayer) { useMQTTViewModel = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, LIVE_POSITIONS_PREF_MQTT_VALUE) .contentEquals(LIVE_POSITIONS_PREF_MQTT_VALUE) if (useMQTTViewModel) livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL) else livePositionsViewModel.requestGTFSUpdates() //mapViewModel.testCascade(); livePositionsViewModel.isLastWorkResultGood.observe(this) { d: Boolean -> Log.d( DEBUG_TAG, "Last trip download result is $d" ) } livePositionsViewModel.tripsGtfsIDsToQuery.observe(this) { dat: List -> Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: $dat") livePositionsViewModel.downloadTripsFromMato(dat) } } fragmentListener?.readyGUIfor(FragmentKind.MAP) + //restore saved state + savedMapStateOnPause?.let { restoreMapStateFromBundle(it) } } override fun onPause() { super.onPause() mapView.onPause() Log.d(DEBUG_TAG, "Fragment paused") savedMapStateOnPause = saveMapStateInBundle() if (useMQTTViewModel) livePositionsViewModel.stopMatoUpdates() } override fun onStop() { super.onStop() mapView.onStop() Log.d(DEBUG_TAG, "Fragment stopped!") stopsViewModel.savedState = Bundle().let { mapView.onSaveInstanceState(it) it } + //save last location + map?.locationComponent?.lastKnownLocation?.let{ + stopsViewModel.lastUserLocation = it + } + } override fun onDestroy() { super.onDestroy() mapView.onDestroy() Log.d(DEBUG_TAG, "Destroyed map Fragment!!") } override fun onMapDestroy() { mapStyle.removeLayer(STOPS_LAYER_ID) mapStyle.removeSource(STOPS_SOURCE_ID) mapStyle.removeLayer(BUSES_LAYER_ID) mapStyle.removeSource(BUSES_SOURCE_ID) map?.locationComponent?.isLocationComponentEnabled = false } override fun getBaseViewForSnackBar(): View? { return mapView } private fun observeStops() { // Observe stops stopsViewModel.stopsToShow.observe(viewLifecycleOwner) { stops -> stopsShowing = ArrayList(stops) displayStops(stopsShowing) initialStopToShow?.let{ s-> //show the stop in the bottom sheet if(!initialStopShown) { openStopInBottomSheet(s) initialStopShown = true } } } } /** * Add the stops to the layers */ private fun displayStops(stops: List?) { if (stops.isNullOrEmpty()) return if (stops.size==lastStopsSizeShown){ Log.d(DEBUG_TAG, "Not updating, have same number of stops. After 3 times") return } /*if(stops.size> lastStopsSizeShown){ stopsRedrawnTimes = 0 } else{ stopsRedrawnTimes++ } */ val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (s in stops){ if (s.latitude!=null && s.longitude!=null) features.add(stopToGeoJsonFeature(s)) } 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)) lastStopsSizeShown = features.size } else map?.let { Log.d(DEBUG_TAG, "Map stop layer is not started yet, init layer") initStopsLayer(mapStyle, FeatureCollection.fromFeatures(features)) Log.d(DEBUG_TAG,"Started stops layer on map") lastStopsSizeShown = features.size stopsLayerStarted = true } } // Hide the bottom sheet and remove extra symbol private fun hideStopBottomSheet(){ /*if (stopActiveSymbol!=null){ symbolManager.delete(stopActiveSymbol) stopActiveSymbol = null } */ //empty the source selectedStopSource.setGeoJson(FeatureCollection.fromFeatures(ArrayList())) bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN //remove initial stop if(initialStopToShow!=null){ initialStopToShow = null } //set showing isBottomSheetShowing = false shownStopInBottomSheet = null } // --------------- BUS LOCATIONS STUFF -------------------------- /** * Start requesting position updates */ private fun startRequestingPositions() { livePositionsViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner) { data: HashMap> -> Log.d( DEBUG_TAG, "Have " + data.size + " trip updates, has Map start finished: " + mapInitCompleted ) if (mapInitCompleted) updateBusPositionsInMap(data) if (!isDetached && !useMQTTViewModel) livePositionsViewModel.requestDelayedGTFSUpdates( 3000 ) } } private fun isInsideVisibleRegion(latitude: Double, longitude: Double, nullValue: Boolean): Boolean{ var isInside = nullValue val visibleRegion = map?.projection?.visibleRegion visibleRegion?.let { val bounds = it.latLngBounds isInside = bounds.contains(LatLng(latitude, longitude)) } return isInside } /*private fun createLabelForVehicle(positionUpdate: LivePositionUpdate){ val symOpt = SymbolOptions() .withLatLng(LatLng(positionUpdate.latitude, positionUpdate.longitude)) .withTextColor("#ffffff") .withTextField(positionUpdate.routeID.substringBeforeLast('U')) .withTextSize(13f) .withTextAnchor(TEXT_ANCHOR_CENTER) .withTextFont(arrayOf( "noto_sans_regular"))//"noto_sans_regular", "sans-serif")) //"noto_sans_regular")) val newSymbol = symbolManager.create(symOpt ) Log.d(DEBUG_TAG, "Symbol for veh ${positionUpdate.vehicle}: $newSymbol") busLabelSymbolsByVeh[positionUpdate.vehicle] = newSymbol } private fun removeVehicleLabel(vehicle: String){ busLabelSymbolsByVeh[vehicle]?.let { symbolManager.delete(it) busLabelSymbolsByVeh.remove(vehicle) } } */ /** * Update function for the bus positions * Takes the processed updates and saves them accordingly */ private fun updateBusPositionsInMap(incomingData: HashMap>){ val vehsNew = HashSet(incomingData.values.map { up -> up.first.vehicle }) val vehsOld = HashSet(positionsByVehDict.keys) val symbolsToUpdate = ArrayList() for (upsWithTrp in incomingData.values){ val pos = upsWithTrp.first val vehID = pos.vehicle var animate = false if (vehsOld.contains(vehID)){ //update position only if the starting or the stopping position of the animation are in the view val oldPos = positionsByVehDict[vehID] var avoidShowingUpdateBecauseIsImpossible = false oldPos?.let{ if(oldPos.routeID!=pos.routeID) { val dist = LatLng(it.latitude, it.longitude).distanceTo(LatLng(pos.latitude, pos.longitude)) val speed = dist*3.6 / (pos.timestamp - it.timestamp) //this should be in km/h Log.w(DEBUG_TAG, "Vehicle $vehID changed route from ${oldPos.routeID} to ${pos.routeID}, distance: $dist, speed: $speed") if (speed > 120 || speed < 0){ avoidShowingUpdateBecauseIsImpossible = true } } } if (avoidShowingUpdateBecauseIsImpossible){ // DO NOT SHOW THIS SHIT Log.w(DEBUG_TAG, "Update for vehicle $vehID skipped") continue } val samePosition = oldPos?.let { (oldPos.latitude==pos.latitude)&&(oldPos.longitude == pos.longitude) }?:false if(!samePosition) { val isPositionInBounds = isInsideVisibleRegion( - pos.latitude, pos.longitude, true - ) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude,true) } ?: false) + pos.latitude, pos.longitude, false + ) || (oldPos?.let { isInsideVisibleRegion(it.latitude,it.longitude, false) } ?: false) if (isPositionInBounds) { //animate = true //this moves both the icon and the label moveVehicleToNewPosition(pos) } else { positionsByVehDict[vehID] = pos /*busLabelSymbolsByVeh[vehID]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } } } else if(pos.latitude>0 && pos.longitude>0) { //we should not have to check for this // update it simply positionsByVehDict[vehID] = pos //createLabelForVehicle(pos) }else{ Log.w(DEBUG_TAG, "Update ignored for veh $vehID on line ${pos.routeID}, lat: ${pos.latitude}, lon ${pos.longitude}") } } // symbolManager.update(symbolsToUpdate) //remove old positions vehsOld.removeAll(vehsNew) //now vehsOld contains the vehicles id for those that have NOT been updated val currentTimeStamp = System.currentTimeMillis() /1000 for(vehID in vehsOld){ //remove after 2 minutes of inactivity if (positionsByVehDict[vehID]!!.timestamp - currentTimeStamp > 2*60){ positionsByVehDict.remove(vehID) //removeVehicleLabel(vehID) } } //update UI updatePositionsIcons() } /** * This is the tricky part, animating the transitions * Basically, we need to set the new positions with the data and redraw them all */ private fun moveVehicleToNewPosition(positionUpdate: LivePositionUpdate){ if (positionUpdate.vehicle !in positionsByVehDict.keys) return val vehID = positionUpdate.vehicle val currentUpdate = positionsByVehDict[positionUpdate.vehicle] currentUpdate?.let { it -> //cancel current animation on vehicle animatorsByVeh[vehID]?.cancel() val currentPos = LatLng(it.latitude, it.longitude) val newPos = LatLng(positionUpdate.latitude, positionUpdate.longitude) val valueAnimator = ValueAnimator.ofObject(MapLibreUtils.LatLngEvaluator(), currentPos, newPos) valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { private var latLng: LatLng? = null override fun onAnimationUpdate(animation: ValueAnimator) { latLng = animation.animatedValue as LatLng //update position on animation val update = positionsByVehDict[positionUpdate.vehicle]!! latLng?.let { ll-> update.latitude = ll.latitude update.longitude = ll.longitude updatePositionsIcons() } } }) valueAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationStart(animation: Animator) { super.onAnimationStart(animation) //val update = positionsByVehDict[positionUpdate.vehicle]!! //remove the label at the start of the animation /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 0.0f symbolsToUpdate.add(sym) } */ } override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) //recreate the label at the end of the animation //createLabelForVehicle(positionUpdate) /*val annot = busLabelSymbolsByVeh[vehID] annot?.let { sym -> sym.textOpacity = 1.0f sym.latLng = newPos //LatLng(newPos) symbolsToUpdate.add(sym) } */ } }) //set the new position as the current one but with the old lat and lng positionUpdate.latitude = currentUpdate.latitude positionUpdate.longitude = currentUpdate.longitude positionsByVehDict[vehID] = positionUpdate valueAnimator.duration = 300 valueAnimator.interpolator = LinearInterpolator() valueAnimator.start() animatorsByVeh[vehID] = valueAnimator } ?: { Log.e(DEBUG_TAG, "Have to run animation for veh ${positionUpdate.vehicle} but not in the dict, adding") positionsByVehDict[positionUpdate.vehicle] = positionUpdate } } /** * Update the bus positions displayed on the map, from the existing data */ private fun updatePositionsIcons(){ //avoid frequent updates val currentTime = System.currentTimeMillis() - if(currentTime - lastUpdateTime < 60){ + //throttle updates when user is moving camera + val interval = if(isUserMovingCamera) 150 else 60 + if(currentTime - lastUpdateTime < interval){ //DO NOT UPDATE THE MAP return } val features = ArrayList()//stops.mapNotNull { stop -> //stop.latitude?.let { lat -> // stop.longitude?.let { lon -> for (pos in positionsByVehDict.values){ //if (s.latitude!=null && s.longitude!=null) val point = Point.fromLngLat(pos.longitude, pos.latitude) features.add( Feature.fromGeometry( point, JsonObject().apply { addProperty("veh", pos.vehicle) addProperty("trip", pos.tripID) addProperty("bearing", pos.bearing ?:0.0f) addProperty("line", pos.routeID.substringBeforeLast('U')) } ) ) /*busLabelSymbolsByVeh[pos.vehicle]?.let { it.latLng = LatLng(pos.latitude, pos.longitude) symbolsToUpdate.add(it) } */ } //this updates the positions busesSource.setGeoJson(FeatureCollection.fromFeatures(features)) //update labels, clear cache to be used //symbolManager.update(symbolsToUpdate) symbolsToUpdate.clear() lastUpdateTime = System.currentTimeMillis() } // ------ LOCATION STUFF ----- @SuppressLint("MissingPermission") private fun requestInitialUserLocation() { val provider : String = LocationManager.GPS_PROVIDER//getBestLocationProvider() //provider.let { setLocationIconEnabled(true) Toast.makeText(requireContext(), R.string.position_searching_message, Toast.LENGTH_SHORT).show() - pendingLocationActivation = true locationManager.requestSingleUpdate(provider, object : LocationListener { override fun onLocationChanged(location: Location) { val userLatLng = LatLng(location.latitude, location.longitude) val distanceToTarget = userLatLng.distanceTo(DEFAULT_LATLNG) if (distanceToTarget <= MAX_DIST_KM*1000.0) { map?.let{ // if we are still waiting for the position to enable if(pendingLocationActivation) setMapLocationEnabled(true, true, false) } } else { - Toast.makeText(context, "You are too far, not showing the position", Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.too_far_not_showing_location, Toast.LENGTH_SHORT).show() + setMapLocationEnabled(false,false, false) } } override fun onProviderDisabled(provider: String) {} override fun onProviderEnabled(provider: String) {} @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} }, null) } /** * Initialize the map location, but do not enable the component */ @SuppressLint("MissingPermission") private fun initMapLocation(style: Style, map: MapLibreMap, context: Context){ locationComponent = map.locationComponent val locationComponentOptions = LocationComponentOptions.builder(context) .pulseEnabled(true) .build() val locationComponentActivationOptions = MapLibreUtils.buildLocationComponentActivationOptions(style, locationComponentOptions, context) locationComponent.activateLocationComponent(locationComponentActivationOptions) locationComponent.isLocationComponentEnabled = false lastLocation?.let { if (it.accuracy < 200) locationComponent.forceLocationUpdate(it) } } /** * Handles logic of enabling the user location on the map */ @SuppressLint("MissingPermission") private fun setMapLocationEnabled(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") + Log.d(DEBUG_TAG, "Permission OK, starting location component, assumed: $assumePermissions, fromClick: $fromClick") locationComponent.isLocationComponentEnabled = true if (initialStopToShow==null) { locationComponent.cameraMode = CameraMode.TRACKING //CameraMode.TRACKING setFollowingUser(true) } setLocationIconEnabled(true) if (fromClick) Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() + pendingLocationActivation =false } 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 setFollowingUser(false) setLocationIconEnabled(false) if (fromClick) { Toast.makeText(requireContext(), R.string.location_disabled, Toast.LENGTH_SHORT).show() if(pendingLocationActivation) pendingLocationActivation=false //Cancel the request for the enablement of the position } } } private fun setLocationIconEnabled(enabled: Boolean){ if (enabled) showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else showUserPositionButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Helper method for GUI */ private fun updateFollowingIcon(enabled: Boolean){ if(enabled) followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_follow_me_on)) else followUserButton.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_follow_me)) } private fun setFollowingUser(following: Boolean){ updateFollowingIcon(following) followingUserLocation = following if(following) ignoreCameraMovementForFollowing = true } private fun switchUserLocationStatus(view: View?){ if(pendingLocationActivation || locationComponent.isLocationComponentEnabled) setMapLocationEnabled(false, false, true) else{ + pendingLocationActivation = true Log.d(DEBUG_TAG, "Request enable location") setMapLocationEnabled(true, false, true) + } } companion object { private const val STOPS_SOURCE_ID = "stops-source" private const val STOPS_LAYER_ID = "stops-layer" private const val STOPS_LAYER_SEL_ID ="stops-layer-selected" private const val LABELS_LAYER_ID = "bus-labels-layer" private const val LABELS_SOURCE = "labels-source" private const val STOP_IMAGE_ID ="bus-stop-icon" const val DEFAULT_CENTER_LAT = 45.0708 const val DEFAULT_CENTER_LON = 7.6858 private val DEFAULT_LATLNG = LatLng(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) + private val DEFAULT_ZOOM = 14.3 private const val POSITION_FOUND_ZOOM = 16.5 private const val NO_POSITION_ZOOM = 17.1 private const val MAX_DIST_KM = 90.0 private const val DEBUG_TAG = "BusTO-MapLibreFrag" private const val STOP_ACTIVE_IMG = "Stop-active" const val FRAGMENT_TAG = "BusTOMapFragment" private const val LOCATION_PERMISSION_REQUEST_CODE = 981202 /** * Use this factory method to create a new instance of * this fragment using the provided parameters. * * @param stop Eventual stop to center the map into * @return A new instance of fragment MapLibreFragment. */ @JvmStatic fun newInstance(stop: Stop?) = MapLibreFragment().apply { arguments = Bundle().let { // Cannot use Parcelable as it requires higher version of Android //stop?.let{putParcelable(STOP_TO_SHOW, it)} stop?.toBundle(it) } } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt b/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt index 0ce8a07..bd8472f 100644 --- a/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt +++ b/app/src/main/java/it/reyboz/bustorino/middleware/AppLocationManager.kt @@ -1,277 +1,278 @@ /* BusTO (middleware) Copyright (C) 2019 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.middleware import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.location.* import android.os.Bundle import android.util.Log import androidx.core.content.ContextCompat import androidx.core.location.LocationListenerCompat import it.reyboz.bustorino.util.LocationCriteria import it.reyboz.bustorino.util.Permissions import java.lang.ref.WeakReference import kotlin.math.min /** * Singleton class used to access location. Possibly extended with other location sources. * * 2024: This is far too much. We need to simplify the whole mechanism (no more singleton) */ class AppLocationManager private constructor(context: Context) : LocationListener { private val appContext: Context private val locMan: LocationManager private val BUNDLE_LOCATION = "location" private var oldGPSLocStatus = LOCATION_UNAVAILABLE private var minimum_time_milli = -1 private val requestersRef = ArrayList>() init { appContext = context.applicationContext locMan = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager } @Throws(SecurityException::class) private fun requestGPSPositionUpdates(): Boolean { val timeinterval = if (minimum_time_milli > 0 && minimum_time_milli < Int.MAX_VALUE) minimum_time_milli else 2000 locMan.removeUpdates(this) if (!checkLocationPermission(appContext)){ Log.e(DEBUG_TAG, "No location permission!!") return false } if (locMan.allProviders.contains("gps")) locMan.requestLocationUpdates( LocationManager.GPS_PROVIDER, timeinterval.toLong(), 5f, this ) /*LocationManagerCompat.requestLocationUpdates(locMan, LocationManager.GPS_PROVIDER, new LocationRequestCompat.Builder(timeinterval).setMinUpdateDistanceMeters(5.F).build(),this, ); TODO: find a way to do this */ return true } private fun cleanAndUpdateRequesters() { minimum_time_milli = Int.MAX_VALUE val iter = requestersRef.listIterator() while (iter.hasNext()) { val cReq = iter.next().get() if (cReq == null) iter.remove() else { minimum_time_milli = min(cReq.locationCriteria.timeInterval.toDouble(), minimum_time_milli.toDouble()) .toInt() } } Log.d( DEBUG_TAG, "Updated requesters, got " + requestersRef.size + " listeners to update every " + minimum_time_milli + " ms at least" ) } fun addLocationRequestFor(req: LocationRequester) { var present = false minimum_time_milli = Int.MAX_VALUE var countNull = 0 val iter = requestersRef.listIterator() while (iter.hasNext()) { val cReq = iter.next().get() if (cReq == null) { countNull++ iter.remove() } else if (cReq == req) { present = true minimum_time_milli = min(cReq.locationCriteria.timeInterval.toDouble(), minimum_time_milli.toDouble()) .toInt() } } Log.d(DEBUG_TAG, "$countNull listeners have been removed because null") if (!present) { val newref = WeakReference(req) requestersRef.add(newref) minimum_time_milli = min(req.locationCriteria.timeInterval.toDouble(), minimum_time_milli.toDouble()) .toInt() Log.d(DEBUG_TAG, "Added new stop requester, instance of " + req.javaClass.simpleName) } if (requestersRef.size > 0) { Log.d(DEBUG_TAG, "Requesting location updates") requestGPSPositionUpdates() } } fun removeLocationRequestFor(req: LocationRequester) { minimum_time_milli = Int.MAX_VALUE val iter = requestersRef.listIterator() while (iter.hasNext()) { val cReq = iter.next().get() if (cReq == null || cReq == req) iter.remove() else { minimum_time_milli = min(cReq.locationCriteria.timeInterval.toDouble(), minimum_time_milli.toDouble()) .toInt() } } if (requestersRef.size <= 0) { locMan.removeUpdates(this) } } private fun sendLocationStatusToAll(status: Int) { val iter = requestersRef.listIterator() while (iter.hasNext()) { val cReq = iter.next().get() if (cReq == null) iter.remove() else cReq.onLocationStatusChanged(status) } } fun isRequesterRegistered(requester: LocationRequester): Boolean { for (regRef in requestersRef) { if (regRef.get() != null && regRef.get() === requester) return true } return false } override fun onLocationChanged(location: Location) { Log.d( DEBUG_TAG, "found location: \nlat: ${location.latitude} lon: ${location.longitude} accuracy: ${location.accuracy}" ) val iter = requestersRef.listIterator() var new_min_interval = Int.MAX_VALUE while (iter.hasNext()) { val requester = iter.next().get() if (requester == null) iter.remove() else { val timeNow = System.currentTimeMillis() val criteria = requester.locationCriteria if (location.accuracy < criteria.minAccuracy && timeNow - requester.lastUpdateTimeMillis > criteria.timeInterval ) { requester.onLocationChanged(location) Log.d( "AppLocationManager", "Updating position for instance of requester " + requester.javaClass.simpleName ) } //update minimum time interval new_min_interval = min(requester.locationCriteria.timeInterval.toDouble(), new_min_interval.toDouble()) .toInt() } } minimum_time_milli = new_min_interval if (requestersRef.size == 0) { //stop requesting the position locMan.removeUpdates(this) } } + @Deprecated("Deprecated in Java") override fun onStatusChanged(provider: String, status: Int, extras: Bundle) { //IF ANOTHER LOCATION SOURCE IS READY, USE IT //OTHERWISE, SIGNAL THAT WE HAVE NO LOCATION if (oldGPSLocStatus != status) { if (status == LocationProvider.OUT_OF_SERVICE || status == LocationProvider.TEMPORARILY_UNAVAILABLE) { sendLocationStatusToAll(LOCATION_UNAVAILABLE) } else if (status == LocationProvider.AVAILABLE) { sendLocationStatusToAll(LOCATION_GPS_AVAILABLE) } oldGPSLocStatus = status } Log.d(DEBUG_TAG, "Provider status changed: $provider status: $status") } override fun onProviderEnabled(provider: String) { cleanAndUpdateRequesters() requestGPSPositionUpdates() Log.d(DEBUG_TAG, "Provider: $provider enabled") for (req in requestersRef) { if (req.get() == null) continue req.get()!!.onLocationProviderAvailable() } } override fun onProviderDisabled(provider: String) { cleanAndUpdateRequesters() for (req in requestersRef) { if (req.get() == null) continue req.get()!!.onLocationDisabled() } //locMan.removeUpdates(this); Log.d(DEBUG_TAG, "Provider: $provider disabled") } fun anyLocationProviderMatchesCriteria(cr: Criteria?): Boolean { return Permissions.anyLocationProviderMatchesCriteria(locMan, cr, true) } /** * Interface to be implemented to get the location request */ interface LocationRequester { /** * Do something with the newly obtained location * @param loc the obtained location */ fun onLocationChanged(loc: Location?) /** * Inform the requester that the GPS status has changed * @param status new status */ fun onLocationStatusChanged(status: Int) /** * We have a location provider available */ fun onLocationProviderAvailable() /** * Called when location is disabled */ fun onLocationDisabled() /** * Give the last time of update the requester has * Set it to -1 in order to receive each new location * @return the time for update in milliseconds since epoch */ val lastUpdateTimeMillis: Long /** * Get the specifications for the location * @return fully parsed LocationCriteria */ val locationCriteria: LocationCriteria } companion object { const val LOCATION_GPS_AVAILABLE = 22 const val LOCATION_UNAVAILABLE = -22 private const val DEBUG_TAG = "BUSTO LocAdapter" private var instance: AppLocationManager? = null @JvmStatic fun getInstance(con: Context): AppLocationManager { if (instance == null) instance = AppLocationManager(con) return instance!! } fun checkLocationPermission(context: Context?): Boolean { return ContextCompat.checkSelfPermission( context!!, Manifest.permission.ACCESS_FINE_LOCATION ) == PackageManager.PERMISSION_GRANTED } } } diff --git a/app/src/main/java/it/reyboz/bustorino/middleware/GeneralActivity.java b/app/src/main/java/it/reyboz/bustorino/middleware/GeneralActivity.java index 78edeee..ff6a2fd 100644 --- a/app/src/main/java/it/reyboz/bustorino/middleware/GeneralActivity.java +++ b/app/src/main/java/it/reyboz/bustorino/middleware/GeneralActivity.java @@ -1,172 +1,191 @@ /* 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.middleware; import android.Manifest; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.content.res.Resources; import android.graphics.Rect; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; +import android.os.Build; import com.google.android.material.snackbar.Snackbar; import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.appcompat.app.AppCompatActivity; import android.util.Log; import android.view.View; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; import java.util.HashMap; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.utils; import it.reyboz.bustorino.data.PreferencesHolder; /** * Activity class that contains all the generally useful methods */ public abstract class GeneralActivity extends AppCompatActivity { final static protected int PERMISSION_REQUEST_POSITION = 33; final static protected String LOCATION_PERMISSION_GIVEN = "loc_permission"; final static protected int STORAGE_PERMISSION_REQ = 291; final static protected int PERMISSION_OK = 0; final static protected int PERMISSION_ASKING = 11; final static protected int PERMISSION_NEG_CANNOT_ASK = -3; final static private String DEBUG_TAG = "BusTO-GeneralAct"; /* * Permission stuff */ protected HashMap permissionDoneRunnables = new HashMap<>(); protected HashMap permissionAsked = new HashMap<>(); protected void setOption(String optionName, boolean value) { SharedPreferences.Editor editor = getPreferences(MODE_PRIVATE).edit(); editor.putBoolean(optionName, value); editor.commit(); } protected boolean getOption(String optionName, boolean optDefault) { SharedPreferences preferences = getPreferences(MODE_PRIVATE); return preferences.getBoolean(optionName, optDefault); } protected SharedPreferences getMainSharedPreferences(){ return PreferencesHolder.getMainSharedPreferences(this); } public void hideKeyboard() { View view = getCurrentFocus(); if (view != null) { ((InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE)) .hideSoftInputFromWindow(view.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); } } public void showToastMessage(int messageID, boolean short_lenght) { final int length = short_lenght ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG; Toast.makeText(getApplicationContext(), messageID, length).show(); } public int askForPermissionIfNeeded(String permission, int requestID){ if(ContextCompat.checkSelfPermission(getApplicationContext(),permission)==PackageManager.PERMISSION_GRANTED){ return PERMISSION_OK; } //need to ask for the permission //consider scenario when we have already asked for permission boolean alreadyAsked = false; Integer num_trials = 0; synchronized (this){ if (permissionAsked.containsKey(permission)){ num_trials = permissionAsked.get(permission); if (num_trials != null && num_trials > 4) alreadyAsked = true; } } Log.d(DEBUG_TAG,"Already asked for permission: "+permission+" -> "+num_trials); if(!alreadyAsked){ ActivityCompat.requestPermissions(this,new String[]{permission}, requestID); synchronized (this){ if (num_trials!=null){ permissionAsked.put(permission, num_trials+1); } } return PERMISSION_ASKING; } else { return PERMISSION_NEG_CANNOT_ASK; } } public void createSnackbar(int ViewID, String message,int duration){ Snackbar.make(findViewById(ViewID),message,duration); } /* METHOD THAT MIGHT BE USEFUL LATER public void assertPermissions(String[] permissions){ ArrayList permissionstoRequest = new ArrayList<>(); for(int i=0;i marginOfError; } @Override protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); } + + protected void setSystemBarAppearance(boolean isSystemInDarkTheme) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (isSystemInDarkTheme) { + if (getWindow() != null && getWindow().getInsetsController() != null) { + getWindow().getInsetsController().setSystemBarsAppearance( + 0, + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ); + } + } else { + if (getWindow() != null && getWindow().getInsetsController() != null) { + getWindow().getInsetsController().setSystemBarsAppearance( + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ); + } + } + } + } + } diff --git a/app/src/main/java/it/reyboz/bustorino/util/ViewUtils.kt b/app/src/main/java/it/reyboz/bustorino/util/ViewUtils.kt index 05ea059..3db75ab 100644 --- a/app/src/main/java/it/reyboz/bustorino/util/ViewUtils.kt +++ b/app/src/main/java/it/reyboz/bustorino/util/ViewUtils.kt @@ -1,159 +1,182 @@ package it.reyboz.bustorino.util import android.content.Context import android.content.Intent import android.content.res.Resources.Theme import android.graphics.Rect import android.net.Uri import android.os.Bundle import android.util.Log import android.util.TypedValue import android.view.View import android.view.WindowManager import android.view.animation.Animation import android.view.animation.Transformation import android.widget.Toast +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.widget.NestedScrollView import it.reyboz.bustorino.R import it.reyboz.bustorino.backend.Stop -import it.reyboz.bustorino.fragments.LinesDetailFragment -import it.reyboz.bustorino.fragments.LinesDetailFragment.Companion import java.io.IOException class ViewUtils { companion object{ + const val DEBUG_TAG="BusTO:ViewUtils" + + /** + * This should help in setting the padding of the last component down + */ + @JvmStatic + fun doOnApplyWindowInsetsForNavigationBars(view: View, listener: OnNavigationBarInsetsAppliedListener) { + ViewCompat.setOnApplyWindowInsetsListener( + view, + OnApplyWindowInsetsListener { v: View?, insets: WindowInsetsCompat? -> + val navigationBarsInsetsBottom = insets!!.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom + listener.onInsetsApplied(v, navigationBarsInsetsBottom) + insets + }) + view.requestApplyInsets() + } + + + interface OnNavigationBarInsetsAppliedListener { + fun onInsetsApplied(view: View?, navigationBarHeight: Int) + } + fun isViewFullyVisibleInScroll(view: View, scrollView: NestedScrollView): Boolean { val scrollBounds = Rect() scrollView.getDrawingRect(scrollBounds) val top = view.y val bottom = top + view.height Log.d(DEBUG_TAG, "Scroll bounds are $scrollBounds, top:${view.y}, bottom $bottom") return (scrollBounds.top < top && scrollBounds.bottom > bottom) } fun isViewPartiallyVisibleInScroll(view: View, scrollView: NestedScrollView): Boolean{ val scrollBounds = Rect() scrollView.getHitRect(scrollBounds) Log.d(DEBUG_TAG, "Scroll bounds are $scrollBounds") if (view.getLocalVisibleRect(scrollBounds)) { return true } else { return false } } //from https://stackoverflow.com/questions/4946295/android-expand-collapse-animation fun expand(v: View,duration: Long, layoutHeight: Int = WindowManager.LayoutParams.WRAP_CONTENT) { val matchParentMeasureSpec = View.MeasureSpec.makeMeasureSpec((v.parent as View).width, View.MeasureSpec.EXACTLY) val wrapContentMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) v.measure(matchParentMeasureSpec, wrapContentMeasureSpec) val targetHeight = v.measuredHeight // Older versions of android (pre API 21) cancel animations for views with a height of 0. v.layoutParams.height = 1 v.visibility = View.VISIBLE val a: Animation = object : Animation() { override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { v.layoutParams.height = if (interpolatedTime == 1f) layoutHeight else (targetHeight * interpolatedTime).toInt() v.requestLayout() } override fun willChangeBounds(): Boolean { return true } } // Expansion speed of 1dp/ms if(duration == DEF_DURATION) a.duration = (targetHeight / v.context.resources.displayMetrics.density).toInt().toLong() else a.duration = duration v.startAnimation(a) } fun collapse(v: View, duration: Long): Animation { val initialHeight = v.measuredHeight val a: Animation = object : Animation() { override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { if (interpolatedTime == 1f) { v.visibility = View.GONE } else { v.layoutParams.height = initialHeight - (initialHeight * interpolatedTime).toInt() v.requestLayout() } } override fun willChangeBounds(): Boolean { return true } } // Collapse speed of 1dp/ms if (duration == DEF_DURATION) a.duration = (initialHeight / v.context.resources.displayMetrics.density).toInt().toLong() else a.duration = duration v.startAnimation(a) return a } const val DEF_DURATION: Long = -2 fun getColorFromTheme(context: Context, resId: Int): Int { val typedValue = TypedValue() val theme: Theme = context.getTheme() theme.resolveAttribute(resId, typedValue, true) val color = typedValue.data return color } fun loadJsonFromAsset(context: Context, fileName: String): String? { return try { context.assets.open(fileName).bufferedReader().use { it.readText() } } catch (e: IOException) { e.printStackTrace() null } } @JvmStatic fun insertSpaces(input: String): String { return input.replace(Regex("(?<=[A-Za-z])(?=[0-9])|(?<=[0-9])(?=[A-Za-z])"), " ") } @JvmStatic fun mergeBundles(bundle1: Bundle?, bundle2: Bundle?): Bundle { if (bundle1 == null) { return if (bundle2 == null) Bundle() else Bundle(bundle2) // Return a copy to avoid modification } if (bundle2 != null) { bundle1.putAll(bundle2) } return bundle1 } @JvmStatic fun openStopInOutsideApp(stop: Stop, context: Context?){ if(stop.latitude==null || stop.longitude==null){ Log.e(DEBUG_TAG, "Navigate to stop but longitude and/or latitude are null") }else{ val stopName = stop.stopUserName ?: stop.stopDefaultName val uri = "geo:?q=${stop.latitude},${stop.longitude}(${stop.ID} - $stopName)" //This below is the full URI, in the correct format. However it seems that apps accept the above one //val uri_real = "geo:${stop.latitude},${stop.longitude}?q=${stop.latitude},${stop.longitude}(${stop.ID} - $stopName)" val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)) context?.run{ if(intent.resolveActivity(packageManager)!=null){ startActivity(intent) } else{ Toast.makeText(this, R.string.no_map_app_to_show_stop, Toast.LENGTH_SHORT).show() } } } } } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt index cc8270b..3ffcbc2 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/StopsMapViewModel.kt @@ -1,89 +1,91 @@ package it.reyboz.bustorino.viewmodels import android.app.Application +import android.location.Location import android.os.Bundle import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.data.NextGenDB import it.reyboz.bustorino.data.OldDataRepository import org.maplibre.android.geometry.LatLngBounds import java.util.concurrent.Executors import kotlin.collections.ArrayList class StopsMapViewModel(application: Application): AndroidViewModel(application) { private val executor = Executors.newFixedThreadPool(2) private val oldRepo = OldDataRepository(executor, NextGenDB.getInstance(application)) val stopsToShow = MutableLiveData(ArrayList()) private var stopsShownIDs = HashSet() private var allStopsLoaded = HashMap() val stopsInBoundingBox = MutableLiveData>() private val callback = OldDataRepository.Callback> { res -> if(res.isSuccess){ stopsInBoundingBox.postValue(res.result) Log.d(DEBUG_TAG, "Setting value of stops in bounding box") } } private val addStopsCallback = OldDataRepository.Callback> { res -> if(res.isSuccess) res.result?.let{ newStops -> val stopsAdd = stopsToShow.value ?: ArrayList() for (s in newStops){ if (s.ID !in stopsShownIDs){ stopsShownIDs.add(s.ID) stopsAdd.add(s) allStopsLoaded[s.ID] = s } } stopsToShow.postValue(stopsAdd) //Log.d(DEBUG_TAG, "Loaded ${stopsAdd.size} stops in total") } } fun getStopByID(id: String): Stop? { if (id in allStopsLoaded) return allStopsLoaded[id] else return null } fun getAllStopsLoaded(): ArrayList{ return ArrayList(allStopsLoaded.values) } /*fun requestStopsInBoundingBox(bb: BoundingBox) { bb.let { Log.d(DEBUG_TAG, "Launching stop request") oldRepo.requestStopsInArea(it.latSouth, it.latNorth, it.lonWest, it.lonEast, callback) } } */ fun requestStopsInLatLng(bb: LatLngBounds) { bb.let { Log.d(DEBUG_TAG, "Launching stop request") oldRepo.requestStopsInArea(it.latitudeSouth, it.latitudeNorth, it.longitudeWest, it.longitudeEast, callback) } } fun loadStopsInLatLngBounds(bb: LatLngBounds?){ bb?.let { Log.d(DEBUG_TAG, "Launching stop request") oldRepo.requestStopsInArea(it.latitudeSouth, it.latitudeNorth, it.longitudeWest, it.longitudeEast, addStopsCallback) } } var savedState: Bundle? = null + var lastUserLocation: Location? = null companion object{ private const val DEBUG_TAG = "BusTOStopMapViewModel" } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_principal.xml b/app/src/main/res/layout/activity_principal.xml index c708cf7..07c0277 100644 --- a/app/src/main/res/layout/activity_principal.xml +++ b/app/src/main/res/layout/activity_principal.xml @@ -1,42 +1,52 @@ - + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_alignParentStart="true" + android:layout_alignParentEnd="true"/> + android:layout_height="wrap_content" + android:layout_alignParentBottom="true" + android:layout_alignParentStart="true" + android:layout_alignParentEnd="true" + /> - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index e433a97..a67e263 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -1,21 +1,32 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/default_toobar.xml b/app/src/main/res/layout/default_toobar.xml index c54f3a1..16fb446 100644 --- a/app/src/main/res/layout/default_toobar.xml +++ b/app/src/main/res/layout/default_toobar.xml @@ -1,17 +1,15 @@ \ No newline at end of file diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml new file mode 100644 index 0000000..3643278 --- /dev/null +++ b/app/src/main/res/values-v35/styles.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a481815..1a4d659 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,362 +1,362 @@ BusTO Libre BusTO BusTO dev BusTO git You\'re using the latest in technology when it comes to respecting your privacy. Search Scan QR Code Yes No Next Previous Install Barcode Scanner? This application requires an app to scan the QR codes. Would you like to install Barcode Scanner now? Bus stop number Bus stop name Insert bus stop number Insert bus stop name %1$s towards %2$s %s (unknown destination) Verify your Internet connection! Seems that no bus stop has this name No arrivals found for this stop Error parsing the 5T/GTT website (damn site!) Name too short, type more characters and retry Arrivals at: %1$s Choose the bus stop… Line Lines Urban lines Extra urban lines Tourist lines No lines found in this category No lines match the searched name Destination: Lines: %1$s Line: %1$s No timetable found No QR code found, try using another app to scan Unexpected internal error, cannot extract data from GTT/5T website Help About the app More about Contribute https://gitpull.it/w/librebusto/en/ Source code Licence11 Meet the author Bus stop is now in your favorites Bus stop removed from your favorites Added line to favorites Remove line from favorites Favorites Favorites Favorites Map No favorites? Arghh! Press on a bus stop star to populate this list! Delete Rename Rename the bus stop Reset About the app Tap the star to add the bus stop to the favourites\n\nHow to read timelines:\n   12:56* Real-time arrivals\n   12:56   Scheduled arrivals\n\nPull down to refresh the timetable \n Long press on Arrivals source to change the source of the arrival times GOT IT! Arrival times No arrivals found for lines: Welcome!

Thanks for using BusTO, an open source and independent app useful to move around Torino using a Free/Libre software.


Why use this app?

- You\'ll never be tracked
- You\'ll never see boring ads
- We\'ll always respect your privacy
- Moreover, it\'s lightweight!


Introductory tutorial

If you want to see the introduction again, use the button below:

]]>
News and Updates

On the Telegram channel, you can find information about the latest app updates

]]>
How does it work?

This app is able to do all the amazing things it does by pulling data from www.gtt.to.it, www.5t.torino.it or muoversiatorino.it "for personal use", along with open data from the AperTO (aperto.comune.torino.it) website.


The work of several people is behind this app, in particular:
- Fabio Mazza, current senior rockstar developer.
- Andrea Ugo, current junior rockstar developer.
- Silviu Chiriac, designer of the 2021 logo.
- Marco M, rockstar tester and bug hunter.
- Ludovico Pavesi, previous senior rockstar developer (asd).
- Valerio Bozzolan, maintainer and infrastructure (sponsor).
- Marco Gagino, contributor and first icon creator.
- JSoup web scraper library.
- makovkastar floating buttons.
- Google for icons and support and design libraries.
- Other icons from Bootstrap, Feather, and Hero Icons.
- All the contributors, and the beta testers, too!


If you want more technical information or to contribute to development, use the buttons below! ]]>
Licenses

The app and the related source code are released by Valerio Bozzolan and the other authors under the terms of the GNU General Public License v3+). So everyone is allowed to use, to study, to improve and to share this app by any kind of means and for any purpose: under the conditions of maintaining this rights and of attributing the original work to Valerio Bozzolan.


Notes

This app has been developed with the hope to be useful to everyone, but comes without ANY warranty of any kind.

The data used by the app comes directly from GTT and other public agencies: if you find any errors, please take it up to them, not to us.

This translation is kindly provided by Riccardo Caniato, Marco Gagino and Fabio Mazza.

Now you can hack public transport, too! :)

]]>
Cannot add to favorites (storage full or corrupted database?)! View on a map Cannot find any application to show it in Cannot find the position of the stop ListFragment - BusTO it.reyboz.bustorino.preferences db_is_updating Nearby stops Nearby connections App version The number of stops to show in the recent stops is invalid Invalid value, put a valid number Finding location No stops nearby Minimum number of stops Preferences Settings Settings General Experimental features Maximum distance (meters) Recent stops General settings Database management Launch manual database update Allow access to location to show it on the map Allow access to location to show stops nearby Please enable location on the device Database update in progress… Updating the database Force database update Touch to update the app database now is arriving at at the stop %1$s - %2$s Show arrivals Show stops Join Telegram channel Show introduction Center on my location Follow me Enable or disable location Location enabled Location disabled Location is disabled on device Arrivals source: %1$s Loading arrivals from %1$s GTT App GTT Website 5T Torino website Muoversi a Torino app Undetermined Changing arrival times source… Long press to change the source of arrivals @string/source_mato @string/fivetapifetcher @string/gttjsonfetcher @string/fivetscraper Sources of arrival times Select which sources of arrival times to use Default Default channel for notifications Database operations Updates of the app database BusTO - live position service Live positions Showing activity related to the live positions service MaTO live bus positions service is running Downloading trips from MaTO server Asked for %1$s permission too many times Cannot use the map with the storage permission! storage The application has crashed because you encountered a bug. \nIf you want, you can help the developers by sending the crash report via email. \nNote that no sensitive data is contained in the report, just small bits of info on your phone and app configuration/state. The application crashed and the crash report is in the attachments. Please describe what you were doing before the crash: \n Arrivals Map Favorites Open navigation drawer Close navigation drawer Experiments Buy us a coffee Map Search by stop Filter by name Launching database update Downloading data from MaTO server Capitalize directions @string/directions_capitalize_no_change @string/directions_capitalize_everything @string/directions_capitalize_first_letter Do not change arrivals directions Capitalize everything Capitalize only first letter KEEP CAPITALIZE_ALL CAPITALIZE_FIRST Section to show on startup Touch to change it Show arrivals touching on stop Enable experiments Long press the stop for options @string/nav_arrivals_text @string/nav_favorites_text @string/nav_map_text @string/lines Source of real time positions for buses and trams MaTO (updated more frequently, might be offline) GTFS RT (more stable, less frequently updated) @string/positions_source_mato_descr @string/positions_source_gtfsrt_descr - + "You are too far, not showing your position Style of the map Versatiles (vector) OSM legacy (raster, lighter) @string/map_style_versatiles @string/map_style_legacy_raster Remove trips data (free up space) All GTFS trips have been removed from the database Show tutorial open source app for Turin public transport. This is an independent app, with no ads and no tracking whatsoever.]]> favorites by touching the star next to its name]]> blue)]]> Settings to customize the app behaviour, and in the About the app section if you want to know more about the app and the developers.]]> Notifications permission to show the information about background processing. Press the button below to grant it]]> Grant location permission Location permission granted Location permission has not been granted OK, close the tutorial Close the tutorial Enable notifications Notifications enabled Backup and restore Import/export preferences Data saved Backup to file Import data from backup Backup has been imported Check at least one item to import! Import favorites from backup Import preferences from backup Hello blank fragment No map app present to show the stop! Direction is already shown Loading destination… Destination unknown
diff --git a/build.gradle b/build.gradle index 0376513..3933e68 100644 --- a/build.gradle +++ b/build.gradle @@ -1,49 +1,50 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { repositories { mavenCentral() maven { url 'https://maven.google.com' } google() maven { url 'https://jitpack.io' } } + //TODO: Migrate tfrom kapt to KSP //kotlin - ext.kotlin_version = '1.9.21' - ext.coroutines_version = "1.8.0" + ext.kotlin_version = '2.1.10' + ext.coroutines_version = "1.10.2" dependencies { - classpath 'com.android.tools.build:gradle:8.5.2' + classpath 'com.android.tools.build:gradle:8.6.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } ext { androidXTestVersion = "1.5.0" //multidex multidex_version = "2.0.1" //libraries versions fragment_version = "1.6.1" activity_version = "1.7.2" - appcompat_version = "1.6.1" + appcompat_version = "1.7.0" preference_version = "1.2.1" work_version = "2.9.0" acra_version = "5.11.3" lifecycle_version = "2.7.0" arch_version = "2.1.0" room_version = "2.5.2" } allprojects { repositories { maven { url 'https://maven.google.com' } google() mavenCentral() maven { url "https://jitpack.io" } } }