diff --git a/app/build.gradle b/app/build.gradle index 8ec0138..c3d1a89 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,143 +1,144 @@ apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-android' apply plugin: 'com.android.application' android { compileSdk 34 namespace "it.reyboz.bustorino" defaultConfig { applicationId "it.reyboz.bustorino" minSdkVersion 21 targetSdkVersion 34 buildToolsVersion = '34.0.0' - versionCode 57 - versionName "2.2.0" + versionCode 58 + versionName "2.2.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 } } 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.9.0" + 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' implementation 'org.osmdroid:osmdroid-android:6.1.10' // 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.hannesa2:paho.mqtt.android:4.2' + implementation 'com.github.hannesa2:paho.mqtt.android:4.2.4' //implementation 'com.github.fabmazz:paho.mqtt.android:v0.0.1' - - // 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2811d9..c7f53e0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,138 +1,141 @@ - + --> + + \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/BustoApp.java b/app/src/main/java/it/reyboz/bustorino/BustoApp.java index a80c054..d9e2916 100644 --- a/app/src/main/java/it/reyboz/bustorino/BustoApp.java +++ b/app/src/main/java/it/reyboz/bustorino/BustoApp.java @@ -1,60 +1,75 @@ /* 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.content.Context; import androidx.multidex.MultiDexApplication; import org.acra.ACRA; import org.acra.BuildConfig; import org.acra.ReportField; import org.acra.config.CoreConfigurationBuilder; import org.acra.config.DialogConfigurationBuilder; import org.acra.config.MailSenderConfigurationBuilder; import org.acra.data.StringFormat; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.List; + import static org.acra.ReportField.*; public class BustoApp extends MultiDexApplication { - private static final ReportField[] REPORT_FIELDS = {REPORT_ID, APP_VERSION_CODE, APP_VERSION_NAME, + private static final List REPORT_FIELDS = List.of(REPORT_ID, APP_VERSION_CODE, APP_VERSION_NAME, PACKAGE_NAME, PHONE_MODEL, BRAND, PRODUCT, ANDROID_VERSION, BUILD_CONFIG, CUSTOM_DATA, IS_SILENT, STACK_TRACE, INITIAL_CONFIGURATION, CRASH_CONFIGURATION, DISPLAY, USER_COMMENT, - USER_APP_START_DATE, USER_CRASH_DATE, LOGCAT, SHARED_PREFERENCES}; + USER_APP_START_DATE, USER_CRASH_DATE, LOGCAT, SHARED_PREFERENCES); @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); - CoreConfigurationBuilder builder = new CoreConfigurationBuilder(this); - builder.setBuildConfigClass(BuildConfig.class).setReportFormat(StringFormat.JSON) - .setDeleteUnapprovedReportsOnApplicationStart(true); - builder.getPluginConfigurationBuilder(MailSenderConfigurationBuilder.class).setMailTo("gtt@succhia.cz") - .setReportFileName(it.reyboz.bustorino.BuildConfig.VERSION_NAME +"_report.json") - .setResBody(R.string.acra_email_message) - .setEnabled(true); - builder.getPluginConfigurationBuilder(DialogConfigurationBuilder.class).setResText(R.string.message_crash) - .setResTheme(R.style.AppTheme) + CoreConfigurationBuilder builder = new CoreConfigurationBuilder(); + // mail stuff + MailSenderConfigurationBuilder mailConfig = new MailSenderConfigurationBuilder(); + mailConfig.withMailTo("gtt@succhia.cz") + .withReportFileName(it.reyboz.bustorino.BuildConfig.VERSION_NAME +"_report.json") + .withBody(getString(R.string.acra_email_message)) .setEnabled(true); + //dialog stuff + DialogConfigurationBuilder dialogBuild = new DialogConfigurationBuilder(); + dialogBuild.withText(getString(R.string.message_crash)) + .withResTheme(R.style.AppTheme).setEnabled(true); + //Set options + builder.withBuildConfigClass(BuildConfig.class) + .withReportFormat(StringFormat.JSON) + .withDeleteUnapprovedReportsOnApplicationStart(true); + //Add plugins + builder.withPluginConfigurations( + mailConfig.build(), dialogBuild.build() + ); + + builder.setReportContent(REPORT_FIELDS); if (!it.reyboz.bustorino.BuildConfig.DEBUG) ACRA.init(this, builder); } } diff --git a/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java b/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java index 2fd2ec3..207d897 100644 --- a/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java +++ b/app/src/main/java/it/reyboz/bustorino/backend/Notifications.java @@ -1,91 +1,121 @@ package it.reyboz.bustorino.backend; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Build; import androidx.core.app.NotificationCompat; import it.reyboz.bustorino.R; public class Notifications { public static final String DEFAULT_CHANNEL_ID ="Default"; public static final String DB_UPDATE_CHANNELS_ID ="Database Update"; + public static final String MATO_LIVE_POSITIONS_CHANNEL="Live Positions"; //match this value to the one used by the MQTTAndroidClient MANUALLY public static void createDefaultNotificationChannel(Context context) { // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { CharSequence name = context.getString(R.string.default_notification_channel); String description = context.getString(R.string.default_notification_channel_description); int importance = NotificationManager.IMPORTANCE_DEFAULT; NotificationChannel channel = new NotificationChannel(DEFAULT_CHANNEL_ID, name, importance); channel.setDescription(description); // Register the channel with the system; you can't change the importance // or other notification behaviors after this NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } /** * Register a notification channel on Android Oreo and above * @param con a Context * @param name channel name * @param description channel description * @param importance channel importance (from NotificationManager) * @param ID channel ID */ public static void createNotificationChannel(Context con, String name, String description, int importance, String ID){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel(ID, name, importance); channel.setDescription(description); // Register the channel with the system; you can't change the importance // or other notification behaviors after this NotificationManager notificationManager = con.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } public static Notification makeMatoDownloadNotification(Context context,String title){ return new NotificationCompat.Builder(context, Notifications.DB_UPDATE_CHANNELS_ID) //.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), Constants.PENDING_INTENT_FLAG_IMMUTABLE)) .setSmallIcon(R.drawable.ic_bus_stilized_transparent) .setOngoing(true) .setAutoCancel(true) .setOnlyAlertOnce(true) .setPriority(NotificationCompat.PRIORITY_MIN) .setContentTitle(context.getString(R.string.app_name)) .setLocalOnly(true) .setVisibility(NotificationCompat.VISIBILITY_SECRET) .setContentText(title) .build(); } + + public static Notification makeLivePositionsNotification(Context context,String title){ + return new NotificationCompat.Builder(context, Notifications.MATO_LIVE_POSITIONS_CHANNEL) + //.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), Constants.PENDING_INTENT_FLAG_IMMUTABLE)) + .setSmallIcon(R.drawable.ic_bus_stilized_transparent) + .setOngoing(true) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setContentTitle(context.getString(R.string.app_name)) + .setLocalOnly(true) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setContentText(title) + .build(); + } public static Notification makeMatoDownloadNotification(Context context){ return makeMatoDownloadNotification(context, context.getString(R.string.downloading_data_mato)); } public static Notification makeMQTTServiceNotification(Context context){ - return makeMatoDownloadNotification(context, context.getString(R.string.mqtt_notification_text)); + return makeLivePositionsNotification(context, context.getString(R.string.mqtt_notification_text)); } public static void cancelNotification(Context context, int notificationID){ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); manager.cancel(notificationID); } public static void createDBNotificationChannel(Context context){ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( Notifications.DB_UPDATE_CHANNELS_ID, context.getString(R.string.database_notification_channel), NotificationManager.IMPORTANCE_MIN ); NotificationManager notificationManager = context.getSystemService(NotificationManager.class); notificationManager.createNotificationChannel(channel); } } + + public static void createLivePositionsChannel(Context context){ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + Notifications.MATO_LIVE_POSITIONS_CHANNEL, + context.getString(R.string.live_positions_notification_channel), + NotificationManager.IMPORTANCE_MIN + ); + channel.setDescription(context.getString(R.string.live_positions_notification_channel_desc)); + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } } diff --git a/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt b/app/src/main/java/it/reyboz/bustorino/backend/mato/MQTTMatoClient.kt index 7df1b0c..e15a1b1 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,404 +1,407 @@ 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) - /*if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){ + // 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) + 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 client!!.connect(options,null, iMqttActionListener) isStarted = true 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){ var done = false for (el in v){ if (el.get()==null){ v.remove(el) } else if(el.get() == responder){ v.remove(el) done = true } if (done) break } if(done) Log.d(DEBUG_TAG, "Removed one listener for line $line, listeners: $v") //if (done) break if (v.isEmpty()){ //actually unsubscribe client!!.unsubscribe( mapTopic(line)) } 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/DBUpdateWorker.java b/app/src/main/java/it/reyboz/bustorino/data/DBUpdateWorker.java index 9ad512e..81866e9 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/DBUpdateWorker.java +++ b/app/src/main/java/it/reyboz/bustorino/data/DBUpdateWorker.java @@ -1,188 +1,189 @@ /* BusTO - Data components Copyright (C) 2021 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.data; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.work.*; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.Fetcher; import it.reyboz.bustorino.backend.Notifications; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static android.content.Context.MODE_PRIVATE; +//TODO: Move to code to Kotlin public class DBUpdateWorker extends Worker{ public static final String ERROR_CODE_KEY ="Error_Code"; public static final String ERROR_REASON_KEY = "ERROR_REASON"; public static final int ERROR_FETCHING_VERSION = 4; public static final int ERROR_DOWNLOADING_STOPS = 5; public static final int ERROR_DOWNLOADING_LINES = 6; public static final int ERROR_CODE_DB_CLOSED=-2; public static final String SUCCESS_REASON_KEY = "SUCCESS_REASON"; public static final int SUCCESS_NO_ACTION_NEEDED = 9; public static final int SUCCESS_UPDATE_DONE = 1; private final static int NOTIFIC_ID =32198; public static final String FORCED_UPDATE = "FORCED-UPDATE"; public static final String DEBUG_TAG = "Busto-UpdateWorker"; private static final long UPDATE_MIN_DELAY= 9*24*3600; //9 days public DBUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { super(context, workerParams); } @SuppressLint("RestrictedApi") @NonNull @Override public Result doWork() { //register Notification channel final Context con = getApplicationContext(); //Notifications.createDefaultNotificationChannel(con); //Use the new notification channels Notifications.createNotificationChannel(con,con.getString(R.string.database_notification_channel), con.getString(R.string.database_notification_channel_desc), NotificationManagerCompat.IMPORTANCE_LOW, Notifications.DB_UPDATE_CHANNELS_ID ); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(getApplicationContext()); final int notification_ID = 32198; final SharedPreferences shPr = con.getSharedPreferences(con.getString(R.string.mainSharedPreferences),MODE_PRIVATE); final int current_DB_version = shPr.getInt(PreferencesHolder.DB_GTT_VERSION_KEY,-10); final int new_DB_version = DatabaseUpdate.getNewVersion(); final boolean isUpdateCompulsory = getInputData().getBoolean(FORCED_UPDATE,false); final long lastDBUpdateTime = shPr.getLong(PreferencesHolder.DB_LAST_UPDATE_KEY, 0); long currentTime = System.currentTimeMillis()/1000; //showNotification(notificationManager, notification_ID); final NotificationCompat.Builder builder = new NotificationCompat.Builder(con, Notifications.DB_UPDATE_CHANNELS_ID) .setContentTitle(con.getString(R.string.database_update_msg_notif)) .setProgress(0,0,true) .setPriority(NotificationCompat.PRIORITY_LOW); builder.setSmallIcon(R.drawable.ic_bus_stilized); notificationManager.notify(notification_ID,builder.build()); Log.d(DEBUG_TAG, "Have previous version: "+current_DB_version +" and new version "+new_DB_version); Log.d(DEBUG_TAG, "Update compulsory: "+isUpdateCompulsory); /* SKIP CHECK (Reason: The Old API might fail at any moment) if (new_DB_version < 0){ //there has been an error final Data out = new Data.Builder().putInt(ERROR_REASON_KEY, ERROR_FETCHING_VERSION) .putInt(ERROR_CODE_KEY,new_DB_version).build(); cancelNotification(notificationID); return ListenableWorker.Result.failure(out); } */ //we got a good version if (!(current_DB_version < new_DB_version || currentTime > lastDBUpdateTime + UPDATE_MIN_DELAY ) && !isUpdateCompulsory) { //don't need to update cancelNotification(notification_ID); return ListenableWorker.Result.success(new Data.Builder(). putInt(SUCCESS_REASON_KEY, SUCCESS_NO_ACTION_NEEDED).build()); } //start the real update AtomicReference resultAtomicReference = new AtomicReference<>(); DatabaseUpdate.setDBUpdatingFlag(con, shPr,true); final DatabaseUpdate.Result resultUpdate = DatabaseUpdate.performDBUpdate(con,resultAtomicReference); DatabaseUpdate.setDBUpdatingFlag(con, shPr,false); if (resultUpdate != DatabaseUpdate.Result.DONE){ //Fetcher.Result result = resultAtomicReference.get(); final Data.Builder dataBuilder = new Data.Builder(); switch (resultUpdate){ case ERROR_STOPS_DOWNLOAD: dataBuilder.put(ERROR_REASON_KEY, ERROR_DOWNLOADING_STOPS); break; case ERROR_LINES_DOWNLOAD: dataBuilder.put(ERROR_REASON_KEY, ERROR_DOWNLOADING_LINES); break; case DB_CLOSED: dataBuilder.put(ERROR_REASON_KEY, ERROR_CODE_DB_CLOSED); break; } cancelNotification(notification_ID); return ListenableWorker.Result.failure(dataBuilder.build()); } Log.d(DEBUG_TAG, "Update finished successfully!"); //update the version in the shared preference final SharedPreferences.Editor editor = shPr.edit(); editor.putInt(PreferencesHolder.DB_GTT_VERSION_KEY, new_DB_version); currentTime = System.currentTimeMillis()/1000; editor.putLong(PreferencesHolder.DB_LAST_UPDATE_KEY, currentTime); editor.apply(); cancelNotification(notification_ID); return ListenableWorker.Result.success(new Data.Builder().putInt(SUCCESS_REASON_KEY, SUCCESS_UPDATE_DONE).build()); } public static Constraints getWorkConstraints(){ return new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresCharging(false).build(); } public static WorkRequest newFirstTimeWorkRequest(){ return new OneTimeWorkRequest.Builder(DBUpdateWorker.class) .setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.SECONDS) //.setInputData(new Data.Builder().putBoolean()) .build(); } /* private int showNotification(@NonNull final NotificationManagerCompat notificManager, final int notification_ID, final String channel_ID){ final NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(), channel_ID) .setContentTitle("Libre BusTO - Updating Database") .setProgress(0,0,true) .setPriority(NotificationCompat.PRIORITY_LOW); builder.setSmallIcon(R.drawable.ic_bus_orange); notificManager.notify(notification_ID,builder.build()); return notification_ID; } */ private void cancelNotification(int notificationID){ final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(getApplicationContext()); notificationManager.cancel(notificationID); } } 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 5e391ef..0f03a28 100644 --- a/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt +++ b/app/src/main/java/it/reyboz/bustorino/data/MatoTripsDownloadWorker.kt @@ -1,135 +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) //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 } - val doInsert = (queriedMatoTrips subtract failedMatoTripsDownload).containsAll(tripsIDsCompleted) - Log.i(DEBUG_TAG, "Inserting missing GtfsTrips in the database, should insert $doInsert") - if(doInsert){ + 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) + gtfsRepository.gtfsDao.insertTrips(downloadedMatoTrips) - } + } - return Result.success() + 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 downloadTripsFromMato(trips: List, context: Context, debugTag: String): Boolean{ - if (trips.isEmpty()) return false + 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 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 true + return requ + } else return null; } } } diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt index 5a15293..98cf68c 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt +++ b/app/src/main/java/it/reyboz/bustorino/fragments/LinesDetailFragment.kt @@ -1,906 +1,906 @@ /* BusTO - Fragments components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.fragments import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.graphics.Paint import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import it.reyboz.bustorino.R import it.reyboz.bustorino.adapters.NameCapitalize import it.reyboz.bustorino.adapters.StopAdapterListener import it.reyboz.bustorino.adapters.StopRecyclerAdapter import it.reyboz.bustorino.backend.FiveTNormalizer import it.reyboz.bustorino.backend.Stop import it.reyboz.bustorino.backend.gtfs.GtfsUtils import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.gtfs.PolylineParser import it.reyboz.bustorino.backend.utils import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.PreferencesHolder import it.reyboz.bustorino.data.gtfs.MatoPattern import it.reyboz.bustorino.data.gtfs.MatoPatternWithStops import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import it.reyboz.bustorino.map.* import it.reyboz.bustorino.map.CustomInfoWindow.TouchResponder import it.reyboz.bustorino.middleware.LocationUtils import it.reyboz.bustorino.util.Permissions import it.reyboz.bustorino.viewmodels.LinesViewModel import it.reyboz.bustorino.viewmodels.LivePositionsViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.MapView import org.osmdroid.views.overlay.FolderOverlay import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList class LinesDetailFragment() : ScreenBaseFragment() { private var lineID = "" private lateinit var patternsSpinner: Spinner private var patternsAdapter: ArrayAdapter? = null //private var patternsSpinnerState: Parcelable? = null private lateinit var currentPatterns: List private lateinit var map: MapView private var viewingPattern: MatoPatternWithStops? = null private val viewModel: LinesViewModel by viewModels() private val mapViewModel: MapViewModel by viewModels() private var firstInit = true private var pausedFragment = false private lateinit var switchButton: ImageButton private var favoritesButton: ImageButton? = null private var locationIcon: ImageButton? = null private var isLineInFavorite = false private var appContext: Context? = null private var isLocationPermissionOK = false private val lineSharedPrefMonitor = SharedPreferences.OnSharedPreferenceChangeListener { pref, keychanged -> if(keychanged!=PreferencesHolder.PREF_FAVORITE_LINES || lineID.isEmpty()) return@OnSharedPreferenceChangeListener val newFavorites = pref.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) newFavorites?.let {favorites-> isLineInFavorite = favorites.contains(lineID) //if the button has been intialized, change the icon accordingly favoritesButton?.let { button-> //avoid crashes if fragment not attached if(context==null) return@let if(isLineInFavorite) { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) appContext?.let { Toast.makeText(it,R.string.favorites_line_add,Toast.LENGTH_SHORT).show()} } else { button.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_outline, null)) appContext?.let {Toast.makeText(it,R.string.favorites_line_remove,Toast.LENGTH_SHORT).show()} } } } } private lateinit var stopsRecyclerView: RecyclerView private lateinit var descripTextView: TextView //adapter for recyclerView private val stopAdapterListener= object : StopAdapterListener { override fun onTappedStop(stop: Stop?) { if(viewModel.shouldShowMessage) { Toast.makeText(context, R.string.long_press_stop_4_options, Toast.LENGTH_SHORT).show() viewModel.shouldShowMessage=false } stop?.let { fragmentListener.requestArrivalsForStopID(it.ID) } if(stop == null){ Log.e(DEBUG_TAG,"Passed wrong stop") } if(fragmentListener == null){ Log.e(DEBUG_TAG, "Fragment listener is null") } } override fun onLongPressOnStop(stop: Stop?): Boolean { TODO("Not yet implemented") } } private var polyline: Polyline? = null //private var stopPosList = ArrayList() private lateinit var stopsOverlay: FolderOverlay private lateinit var locationOverlay: LocationOverlay private val locationOverlayResponder = object : LocationOverlay.OverlayCallbacks{ override fun onDisableFollowMyLocation() { Log.d(DEBUG_TAG, "Follow location disabled") } override fun onEnableFollowMyLocation() { Log.d(DEBUG_TAG, "Follow location enabled") } } //location request responder private val locationRequestResLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()){ res -> //onActivityResult(res: map) if(res[Permissions.LOCATION_PERMISSIONS[0]] ==true || res[Permissions.LOCATION_PERMISSIONS[1]] ==true) locationIcon?.let { onPositionIconButtonClick(it) } else{ context?.let { Toast.makeText(it,R.string.location_permission_not_granted, Toast.LENGTH_SHORT).show() } } } //fragment actions private lateinit var fragmentListener: CommonFragmentListener private val stopTouchResponder = TouchResponder { stopID, stopName -> Log.d(DEBUG_TAG, "Asked to show arrivals for stop ID: $stopID") fragmentListener.requestArrivalsForStopID(stopID) } private var showOnTopOfLine = true private var recyclerInitDone = false private var useMQTTPositions = true //position of live markers private val busPositionMarkersByTrip = HashMap() private var busPositionsOverlay = FolderOverlay() private val tripMarkersAnimators = HashMap() private val liveBusViewModel: LivePositionsViewModel by viewModels() @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val rootView = inflater.inflate(R.layout.fragment_lines_detail, container, false) lineID = requireArguments().getString(LINEID_KEY, "") switchButton = rootView.findViewById(R.id.switchImageButton) locationIcon = rootView.findViewById(R.id.locationEnableIcon) favoritesButton = rootView.findViewById(R.id.favoritesButton) stopsRecyclerView = rootView.findViewById(R.id.patternStopsRecyclerView) descripTextView = rootView.findViewById(R.id.lineDescripTextView) descripTextView.visibility = View.INVISIBLE val titleTextView = rootView.findViewById(R.id.titleTextView) titleTextView.text = getString(R.string.line)+" "+FiveTNormalizer.fixShortNameForDisplay( GtfsUtils.getLineNameFromGtfsID(lineID), true) favoritesButton?.isClickable = true favoritesButton?.setOnClickListener { if(lineID.isNotEmpty()) PreferencesHolder.addOrRemoveLineToFavorites(requireContext(),lineID,!isLineInFavorite) } val preferences = PreferencesHolder.getMainSharedPreferences(requireContext()) val favorites = preferences.getStringSet(PreferencesHolder.PREF_FAVORITE_LINES, HashSet()) if(favorites!=null && favorites.contains(lineID)){ favoritesButton?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_star_filled, null)) isLineInFavorite = true } appContext = requireContext().applicationContext preferences.registerOnSharedPreferenceChangeListener(lineSharedPrefMonitor) patternsSpinner = rootView.findViewById(R.id.patternsSpinner) patternsAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, ArrayList()) patternsSpinner.adapter = patternsAdapter initializeMap(rootView) initializeRecyclerView() switchButton.setOnClickListener{ if(map.visibility == View.VISIBLE){ map.visibility = View.GONE stopsRecyclerView.visibility = View.VISIBLE locationIcon?.visibility = View.GONE viewModel.setMapShowing(false) liveBusViewModel.stopMatoUpdates() //map.overlayManager.remove(busPositionsOverlay) switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_map_white_30)) } else{ stopsRecyclerView.visibility = View.GONE map.visibility = View.VISIBLE locationIcon?.visibility = View.VISIBLE viewModel.setMapShowing(true) //map.overlayManager.add(busPositionsOverlay) //map. if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() switchButton.setImageDrawable(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_list_30)) } } locationIcon?.let {view -> if(!LocationUtils.isLocationEnabled(requireContext()) || !Permissions.anyLocationPermissionsGranted(requireContext())) setLocationIconEnabled(false) //set click Listener view.setOnClickListener(this::onPositionIconButtonClick) } //set viewModel.setRouteIDQuery(lineID) val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") viewModel.patternsWithStopsByRouteLiveData.observe(viewLifecycleOwner){ patterns -> savePatternsToShow(patterns) } /* We have the pattern and the stops here, time to display them */ viewModel.stopsForPatternLiveData.observe(viewLifecycleOwner) { stops -> if(map.visibility ==View.VISIBLE) showPatternWithStopsOnMap(stops) else{ if(stopsRecyclerView.visibility==View.VISIBLE) showStopsAsList(stops) } } viewModel.gtfsRoute.observe(viewLifecycleOwner){route-> if(route == null){ //need to close the fragment activity?.supportFragmentManager?.popBackStack() return@observe } descripTextView.text = route.longName descripTextView.visibility = View.VISIBLE } if(pausedFragment && viewModel.selectedPatternLiveData.value!=null){ val patt = viewModel.selectedPatternLiveData.value!! Log.d(DEBUG_TAG, "Recreating views on resume, setting pattern: ${patt.pattern.code}") showPattern(patt) pausedFragment = false } Log.d(DEBUG_TAG,"Data ${viewModel.stopsForPatternLiveData.value}") //listeners patternsSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) { val patternWithStops = currentPatterns.get(position) //viewModel.setPatternToDisplay(patternWithStops) setPatternAndReqStops(patternWithStops) Log.d(DEBUG_TAG, "item Selected, cleaning bus markers") if(map?.visibility == View.VISIBLE) { busPositionsOverlay.closeAllInfoWindows() busPositionsOverlay.items.clear() busPositionMarkersByTrip.clear() stopAnimations() tripMarkersAnimators.clear() liveBusViewModel.retriggerPositionUpdate() } } override fun onNothingSelected(p0: AdapterView<*>?) { } } //live bus positions liveBusViewModel.updatesWithTripAndPatterns.observe(viewLifecycleOwner){ if(map.visibility == View.GONE || viewingPattern ==null){ //DO NOTHING return@observe } val filtdLineID = GtfsUtils.stripGtfsPrefix(lineID) //filter buses with direction, show those only with the same direction val outmap = HashMap>() val currentPattern = viewingPattern!!.pattern val numUpds = it.entries.size Log.d(DEBUG_TAG, "Got $numUpds updates, current pattern is: ${currentPattern.name}, directionID: ${currentPattern.directionId}") val patternsDirections = HashMap() for((tripId, pair) in it.entries){ //remove trips with wrong line ideas if(pair.first.routeID!=filtdLineID) continue if(pair.second!=null && pair.second?.pattern !=null){ val dir = pair.second?.pattern?.directionId if(dir !=null && dir == currentPattern.directionId){ outmap[tripId] = pair } patternsDirections.set(tripId,if (dir!=null) dir else -10) } else{ outmap[tripId] = pair //Log.d(DEBUG_TAG, "No pattern for tripID: $tripId") patternsDirections[tripId] = -10 } } Log.d(DEBUG_TAG, " Filtered updates are ${outmap.keys.size}") // Original updates directs: $patternsDirections\n updateBusPositionsInMap(outmap) //if not using MQTT positions if(!useMQTTPositions){ liveBusViewModel.requestDelayedGTFSUpdates(2000) } } //download missing tripIDs liveBusViewModel.tripsGtfsIDsToQuery.observe(viewLifecycleOwner){ //gtfsPosViewModel.downloadTripsFromMato(dat); - MatoTripsDownloadWorker.downloadTripsFromMato( + MatoTripsDownloadWorker.requestMatoTripsDownload( it, requireContext().applicationContext, "BusTO-MatoTripDownload" ) } return rootView } private fun setLocationIconEnabled(setTrue: Boolean){ if(setTrue) locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_red)) else locationIcon?.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.location_circlew_grey)) } /** * Switch position icon from activ */ private fun onPositionIconButtonClick(view: View){ if(locationOverlay.isMyLocationEnabled){ //switch off locationOverlay.disableMyLocation() //set image on respective button setLocationIconEnabled(false) if(context!=null) { if (LocationUtils.isLocationEnabled(context)) { //show message Toast.makeText(context, R.string.location_disabled, Toast.LENGTH_SHORT).show() } } } else{ //switch on locationOverlay.enableMyLocation() if(context!=null) { if(!Permissions.anyLocationPermissionsGranted(context)) { locationRequestResLauncher.launch(Permissions.LOCATION_PERMISSIONS) Toast.makeText(context, R.string.enable_position_message_map, Toast.LENGTH_SHORT).show() } else if (LocationUtils.isLocationEnabled(context)) { //set image on button setLocationIconEnabled(true) //show message Toast.makeText(context, R.string.location_enabled, Toast.LENGTH_SHORT).show() } else{ Toast.makeText(context, R.string.map_location_disabled_device, Toast.LENGTH_SHORT).show() } } } } private fun initializeMap(rootView : View){ val ctx = requireContext().applicationContext Configuration.getInstance().load(ctx, PreferenceManager.getDefaultSharedPreferences(ctx)) map = rootView.findViewById(R.id.lineMap) map.let { it.setTileSource(TileSourceFactory.MAPNIK) locationOverlay = LocationOverlay.createLocationOverlay(true, it, requireContext(), locationOverlayResponder) locationOverlay.disableFollowLocation() stopsOverlay = FolderOverlay() busPositionsOverlay = FolderOverlay() //map.setTilesScaledToDpi(true); //map.setTilesScaledToDpi(true); it.setFlingEnabled(true) it.setUseDataConnection(true) // add ability to zoom with 2 fingers it.setMultiTouchControls(true) it.minZoomLevel = 12.0 //map controller setup val mapController = it.controller var zoom = 12.0 var centerMap = GeoPoint(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON) if(mapViewModel.currentLat.value!=MapViewModel.INVALID) { Log.d(DEBUG_TAG, "mapViewModel posi: ${mapViewModel.currentLat.value}, ${mapViewModel.currentLong.value}"+ " zoom ${mapViewModel.currentZoom.value}") zoom = mapViewModel.currentZoom.value!! centerMap = GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!) /*viewLifecycleOwner.lifecycleScope.launch { delay(100) Log.d(DEBUG_TAG, "zooming back to point") controller.animateTo(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!), mapViewModel.currentZoom.value!!,null,null) //controller.setCenter(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!)) //controller.setZoom(mapViewModel.currentZoom.value!!) */ } mapController.setZoom(zoom) mapController.setCenter(centerMap) Log.d(DEBUG_TAG, "Initializing map, first init $firstInit") //map.invalidate() it.overlayManager.add(stopsOverlay) it.overlayManager.add(locationOverlay) it.overlayManager.add(busPositionsOverlay) zoomToCurrentPattern() firstInit = false } } override fun onAttach(context: Context) { super.onAttach(context) if(context is CommonFragmentListener){ fragmentListener = context } else throw RuntimeException("$context must implement CommonFragmentListener") } private fun stopAnimations(){ for(anim in tripMarkersAnimators.values){ anim.cancel() } } private fun savePatternsToShow(patterns: List){ val patternsSorter = Comparator{ p1: MatoPatternWithStops, p2: MatoPatternWithStops -> if(p1.pattern.directionId != p2.pattern.directionId) return@Comparator p1.pattern.directionId - p2.pattern.directionId else return@Comparator -1*(p1.stopsIndices.size - p2.stopsIndices.size) } currentPatterns = patterns.sortedWith(patternsSorter) patternsAdapter?.let { it.clear() it.addAll(currentPatterns.map { p->"${p.pattern.directionId} - ${p.pattern.headsign}" }) it.notifyDataSetChanged() } viewingPattern?.let { showPattern(it) } } /** * Called when the position of the spinner is updated */ private fun setPatternAndReqStops(patternWithStops: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Requesting stops for pattern ${patternWithStops.pattern.code}") viewModel.selectedPatternLiveData.value = patternWithStops viewModel.currentPatternStops.value = patternWithStops.stopsIndices.sortedBy { i-> i.order } viewingPattern = patternWithStops viewModel.requestStopsForPatternWithStops(patternWithStops) } private fun showPattern(patternWs: MatoPatternWithStops){ Log.d(DEBUG_TAG, "Finding pattern to show: ${patternWs.pattern.code}") var pos = -2 val code = patternWs.pattern.code.trim() for(k in currentPatterns.indices){ if(currentPatterns[k].pattern.code.trim() == code){ pos = k break } } Log.d(DEBUG_TAG, "Found pattern $code in position: $pos") if(pos>=0) patternsSpinner.setSelection(pos) //set pattern setPatternAndReqStops(patternWs) } private fun zoomToCurrentPattern(){ var pointsList: List if(viewingPattern==null) { Log.e(DEBUG_TAG, "asked to zoom to pattern but current viewing pattern is null") if(polyline!=null) pointsList = polyline!!.actualPoints else { Log.d(DEBUG_TAG, "The polyline is null") return } }else{ val pattern = viewingPattern!!.pattern pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) } var maxLat = -4000.0 var minLat = -4000.0 var minLong = -4000.0 var maxLong = -4000.0 for (p in pointsList){ // get max latitude if(maxLat == -4000.0) maxLat = p.latitude else if (maxLat < p.latitude) maxLat = p.latitude // find min latitude if (minLat == -4000.0) minLat = p.latitude else if (minLat > p.latitude) minLat = p.latitude if(maxLong == -4000.0 || maxLong < p.longitude ) maxLong = p.longitude if (minLong == -4000.0 || minLong > p.longitude) minLong = p.longitude } val del = 0.008 //map.controller.c Log.d(DEBUG_TAG, "Setting limits of bounding box of line: $minLat -> $maxLat, $minLong -> $maxLong") map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), false) } private fun showPatternWithStopsOnMap(stops: List){ Log.d(DEBUG_TAG, "Got the stops: ${stops.map { s->s.gtfsID }}}") if(viewingPattern==null || map == null) return val pattern = viewingPattern!!.pattern val pointsList = PolylineParser.decodePolyline(pattern.patternGeometryPoly, pattern.patternGeometryLength) var maxLat = -4000.0 var minLat = -4000.0 var minLong = -4000.0 var maxLong = -4000.0 for (p in pointsList){ // get max latitude if(maxLat == -4000.0) maxLat = p.latitude else if (maxLat < p.latitude) maxLat = p.latitude // find min latitude if (minLat == -4000.0) minLat = p.latitude else if (minLat > p.latitude) minLat = p.latitude if(maxLong == -4000.0 || maxLong < p.longitude ) maxLong = p.longitude if (minLong == -4000.0 || minLong > p.longitude) minLong = p.longitude } //val polyLine=Polyline(map) //polyLine.setPoints(pointsList) //save points if(map.overlayManager.contains(polyline)){ map.overlayManager.remove(polyline) } polyline = Polyline(map, false) polyline!!.setPoints(pointsList) //polyline.color = ContextCompat.getColor(context!!,R.color.brown_vd) polyline!!.infoWindow = null val paint = Paint() paint.color = ContextCompat.getColor(requireContext(),R.color.line_drawn_poly) paint.isAntiAlias = true paint.strokeWidth = 13f paint.style = Paint.Style.FILL_AND_STROKE paint.strokeJoin = Paint.Join.ROUND paint.strokeCap = Paint.Cap.ROUND polyline!!.outlinePaintLists.add(MonochromaticPaintList(paint)) map.overlayManager.add(0,polyline!!) stopsOverlay.closeAllInfoWindows() stopsOverlay.items.clear() val stopIcon = ContextCompat.getDrawable(requireContext(), R.drawable.ball) for(s in stops){ val gp = if (showOnTopOfLine) findOptimalPosition(s,pointsList) else GeoPoint(s.latitude!!,s.longitude!!) val marker = MarkerUtils.makeMarker( gp, s.ID, s.stopDefaultName, s.routesThatStopHereToString(), map,stopTouchResponder, stopIcon, R.layout.linedetail_stop_infowindow, R.color.line_drawn_poly ) marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) stopsOverlay.add(marker) } //POINTS LIST IS NOT IN ORDER ANY MORE //if(!map.overlayManager.contains(stopsOverlay)){ // map.overlayManager.add(stopsOverlay) //} polyline!!.setOnClickListener(Polyline.OnClickListener { polyline, mapView, eventPos -> Log.d(DEBUG_TAG, "clicked") true }) //map.controller.zoomToB//#animateTo(pointsList[0]) val del = 0.008 map.zoomToBoundingBox(BoundingBox(maxLat+del, maxLong+del, minLat-del, minLong-del), true) //map.invalidate() } private fun initializeRecyclerView(){ val llManager = LinearLayoutManager(context) llManager.orientation = LinearLayoutManager.VERTICAL stopsRecyclerView.layoutManager = llManager } private fun showStopsAsList(stops: List){ Log.d(DEBUG_TAG, "Setting stops from: "+viewModel.currentPatternStops.value) val orderBy = viewModel.currentPatternStops.value!!.withIndex().associate{it.value.stopGtfsId to it.index} val stopsSorted = stops.sortedBy { s -> orderBy[s.gtfsID] } val numStops = stopsSorted.size Log.d(DEBUG_TAG, "RecyclerView adapter is: ${stopsRecyclerView.adapter}") val setNewAdapter = true if(setNewAdapter){ stopsRecyclerView.adapter = StopRecyclerAdapter( stopsSorted, stopAdapterListener, StopRecyclerAdapter.Use.LINES, NameCapitalize.FIRST ) } } /** * Remove bus marker from overlay associated with tripID */ private fun removeBusMarker(tripID: String){ if(!busPositionMarkersByTrip.containsKey(tripID)){ Log.e(DEBUG_TAG, "Asked to remove veh with tripID $tripID but it's supposedly not shown") return } val marker = busPositionMarkersByTrip[tripID] busPositionsOverlay.remove(marker) busPositionMarkersByTrip.remove(tripID) val animator = tripMarkersAnimators[tripID] animator?.let{ it.cancel() tripMarkersAnimators.remove(tripID) } } private fun showPatternWithStop(patternId: String){ //var index = 0 Log.d(DEBUG_TAG, "Showing pattern with code $patternId ") for (i in currentPatterns.indices){ val pattStop = currentPatterns[i] if(pattStop.pattern.code == patternId){ Log.d(DEBUG_TAG, "Pattern found in position $i") //setPatternAndReqStops(pattStop) patternsSpinner.setSelection(i) break } } } /** * draw the position of the buses in the map. Copied from MapFragment */ private fun updateBusPositionsInMap(tripsPatterns: java.util.HashMap> ) { //Log.d(MapFragment.DEBUG_TAG, "Updating positions of the buses") //if(busPositionsOverlay == null) busPositionsOverlay = new FolderOverlay(); // cleanup the patterns // at first run, the buses which have no direction are still displayed. If those become missing in the data, // it becomes clear that they don't have the same direction val currentBusesTripsIds = HashSet(busPositionMarkersByTrip.keys) for (tripID in currentBusesTripsIds){ if (!tripsPatterns.keys.contains(tripID)){ //the tripId is not in the updates anymore, remove it removeBusMarker(tripID) } } val noPatternsTrips = ArrayList() for (tripID in tripsPatterns.keys) { val (update, tripWithPatternStops) = tripsPatterns[tripID] ?: continue var marker: Marker? = null //check if Marker is already created if (busPositionMarkersByTrip.containsKey(tripID)) { //check if the trip direction ID is the same, if not remove if(tripWithPatternStops?.pattern != null && tripWithPatternStops.pattern.directionId != viewingPattern?.pattern?.directionId){ removeBusMarker(tripID) } else { //need to change the position of the marker marker = busPositionMarkersByTrip.get(tripID)!! BusPositionUtils.updateBusPositionMarker(map, marker, update, tripMarkersAnimators, false) // Set the pattern to add the info if (marker.infoWindow != null && marker.infoWindow is BusInfoWindow) { val window = marker.infoWindow as BusInfoWindow if (window.pattern == null && tripWithPatternStops != null) { //Log.d(DEBUG_TAG, "Update pattern for trip: "+tripID); window.setPatternAndDraw(tripWithPatternStops.pattern) } } } } else { //marker is not there, need to make it //if (mapView == null) Log.e(MapFragment.DEBUG_TAG, "Creating marker with null map, things will explode") marker = Marker(map) //String route = GtfsUtils.getLineNameFromGtfsID(update.getRouteID()); val mdraw = ResourcesCompat.getDrawable(getResources(), R.drawable.map_bus_position_icon, null)!! //mdraw.setBounds(0,0,28,28); marker.icon = mdraw var markerPattern: MatoPattern? = null if (tripWithPatternStops != null) { if (tripWithPatternStops.pattern != null) markerPattern = tripWithPatternStops.pattern } marker.infoWindow = BusInfoWindow(map, update, markerPattern, true) { // set pattern to show if(it!=null) showPatternWithStop(it.code) } //marker.infoWindow as BusInfoWindow marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) BusPositionUtils.updateBusPositionMarker(map,marker, update, tripMarkersAnimators,true) // the overlay is null when it's not attached yet? // cannot recreate it because it becomes null very soon // if(busPositionsOverlay == null) busPositionsOverlay = new FolderOverlay(); //save the marker if (busPositionsOverlay != null) { busPositionsOverlay.add(marker) busPositionMarkersByTrip.put(tripID, marker) } } } if (noPatternsTrips.size > 0) { Log.i(DEBUG_TAG, "These trips have no matching pattern: $noPatternsTrips") } } override fun onResume() { super.onResume() Log.d(DEBUG_TAG, "Resetting paused from onResume") pausedFragment = false val keySourcePositions = getString(R.string.pref_positions_source) useMQTTPositions = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions, "mqtt").contentEquals("mqtt") //separate paths if(useMQTTPositions) liveBusViewModel.requestMatoPosUpdates(GtfsUtils.getLineNameFromGtfsID(lineID)) else liveBusViewModel.requestGTFSUpdates() if(mapViewModel.currentLat.value!=MapViewModel.INVALID) { Log.d(DEBUG_TAG, "mapViewModel posi: ${mapViewModel.currentLat.value}, ${mapViewModel.currentLong.value}"+ " zoom ${mapViewModel.currentZoom.value}") val controller = map.controller viewLifecycleOwner.lifecycleScope.launch { delay(100) Log.d(DEBUG_TAG, "zooming back to point") controller.animateTo(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!), mapViewModel.currentZoom.value!!,null,null) //controller.setCenter(GeoPoint(mapViewModel.currentLat.value!!, mapViewModel.currentLong.value!!)) //controller.setZoom(mapViewModel.currentZoom.value!!) } //controller.setZoom() } //initialize GUI here fragmentListener.readyGUIfor(FragmentKind.LINES) } override fun onPause() { super.onPause() liveBusViewModel.stopMatoUpdates() pausedFragment = true //save map val center = map.mapCenter mapViewModel.currentLat.value = center.latitude mapViewModel.currentLong.value = center.longitude mapViewModel.currentZoom.value = map.zoomLevel.toDouble() } override fun getBaseViewForSnackBar(): View? { return null } companion object { private const val LINEID_KEY="lineID" fun newInstance() = LinesDetailFragment() const val DEBUG_TAG="LinesDetailFragment" fun makeArgs(lineID: String): Bundle{ val b = Bundle() b.putString(LINEID_KEY, lineID) return b } @JvmStatic private fun findOptimalPosition(stop: Stop, pointsList: MutableList): GeoPoint{ if(stop.latitude==null || stop.longitude ==null|| pointsList.isEmpty()) throw IllegalArgumentException() val sLat = stop.latitude!! val sLong = stop.longitude!! if(pointsList.size < 2) return pointsList[0] pointsList.sortBy { utils.measuredistanceBetween(sLat, sLong, it.latitude, it.longitude) } val p1 = pointsList[0] val p2 = pointsList[1] if (p1.longitude == p2.longitude){ //Log.e(DEBUG_TAG, "Same longitude") return GeoPoint(sLat, p1.longitude) } else if (p1.latitude == p2.latitude){ //Log.d(DEBUG_TAG, "Same latitude") return GeoPoint(p2.latitude,sLong) } val m = (p1.latitude - p2.latitude) / (p1.longitude - p2.longitude) val minv = (p1.longitude-p2.longitude)/(p1.latitude - p2.latitude) val cR = p1.latitude - p1.longitude * m val longNew = (minv * sLong + sLat -cR ) / (m+minv) val latNew = (m*longNew + cR) //Log.d(DEBUG_TAG,"Stop ${stop.ID} old pos: ($sLat, $sLong), new pos ($latNew,$longNew)") return GeoPoint(latNew,longNew) } private const val DEFAULT_CENTER_LAT = 45.12 private const val DEFAULT_CENTER_LON = 7.6858 } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/fragments/MapFragment.java b/app/src/main/java/it/reyboz/bustorino/fragments/MapFragment.java index 0035c62..6e3992e 100644 --- a/app/src/main/java/it/reyboz/bustorino/fragments/MapFragment.java +++ b/app/src/main/java/it/reyboz/bustorino/fragments/MapFragment.java @@ -1,833 +1,836 @@ /* BusTO - Fragments components Copyright (C) 2020 Andrea Ugo 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.fragments; import android.Manifest; import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.drawable.Drawable; import android.location.Location; 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.widget.ImageButton; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.res.ResourcesCompat; import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate; import it.reyboz.bustorino.backend.mato.MQTTMatoClient; import it.reyboz.bustorino.backend.utils; -import it.reyboz.bustorino.data.MatoTripsDownloadWorker; import it.reyboz.bustorino.data.gtfs.MatoPattern; import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops; import it.reyboz.bustorino.map.*; import it.reyboz.bustorino.viewmodels.LivePositionsViewModel; import it.reyboz.bustorino.viewmodels.StopsMapViewModel; import org.osmdroid.api.IGeoPoint; import org.osmdroid.api.IMapController; import org.osmdroid.config.Configuration; import org.osmdroid.events.DelayedMapListener; import org.osmdroid.events.MapListener; import org.osmdroid.events.ScrollEvent; import org.osmdroid.events.ZoomEvent; import org.osmdroid.tileprovider.tilesource.TileSourceFactory; import org.osmdroid.util.BoundingBox; import org.osmdroid.util.GeoPoint; import org.osmdroid.views.MapView; import org.osmdroid.views.overlay.FolderOverlay; import org.osmdroid.views.overlay.Marker; import org.osmdroid.views.overlay.infowindow.InfoWindow; import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider; import java.util.*; import kotlin.Pair; import it.reyboz.bustorino.R; import it.reyboz.bustorino.backend.Stop; import it.reyboz.bustorino.middleware.GeneralActivity; import it.reyboz.bustorino.util.Permissions; import static it.reyboz.bustorino.fragments.SettingsFragment.LIVE_POSITIONS_PREF_MQTT_VALUE; public class MapFragment extends ScreenBaseFragment { //private static final String TAG = "Busto-MapActivity"; private static final String MAP_CURRENT_ZOOM_KEY = "map-current-zoom"; private static final String MAP_CENTER_LAT_KEY = "map-center-lat"; private static final String MAP_CENTER_LON_KEY = "map-center-lon"; private static final String FOLLOWING_LOCAT_KEY ="following"; public static final String BUNDLE_LATIT = "lat"; public static final String BUNDLE_LONGIT = "lon"; public static final String BUNDLE_NAME = "name"; public static final String BUNDLE_ID = "ID"; public static final String BUNDLE_ROUTES_STOPPING = "routesStopping"; public static final String FRAGMENT_TAG="BusTOMapFragment"; private static final double DEFAULT_CENTER_LAT = 45.0708; private static final double DEFAULT_CENTER_LON = 7.6858; private static final double POSITION_FOUND_ZOOM = 18.3; public static final double NO_POSITION_ZOOM = 17.1; private static final String DEBUG_TAG=FRAGMENT_TAG; protected FragmentListenerMain listenerMain; private HashSet shownStops = null; private MapView map = null; public Context ctx; private LocationOverlay mLocationOverlay = null; private FolderOverlay stopsFolderOverlay = null; private Bundle savedMapState = null; protected ImageButton btCenterMap; protected ImageButton btFollowMe; protected CoordinatorLayout coordLayout; private boolean hasMapStartFinished = false; private boolean followingLocation = false; //the ViewModel from which we get the stop to display in the map private StopsMapViewModel stopsViewModel; //private GtfsPositionsViewModel gtfsPosViewModel; //= new ViewModelProvider(this).get(MapViewModel.class); private LivePositionsViewModel livePositionsViewModel; private Boolean useMQTTViewModel = true; private final HashMap busPositionMarkersByTrip = new HashMap<>(); private FolderOverlay busPositionsOverlay = null; private final HashMap tripMarkersAnimators = new HashMap<>(); protected final CustomInfoWindow.TouchResponder responder = new CustomInfoWindow.TouchResponder() { @Override public void onActionUp(@NonNull String stopID, @Nullable String stopName) { if (listenerMain!= null){ Log.d(DEBUG_TAG, "Asked to show arrivals for stop ID: "+stopID); listenerMain.requestArrivalsForStopID(stopID); } } }; protected final LocationOverlay.OverlayCallbacks locationCallbacks = new LocationOverlay.OverlayCallbacks() { @Override public void onDisableFollowMyLocation() { updateGUIForLocationFollowing(false); followingLocation=false; } @Override public void onEnableFollowMyLocation() { updateGUIForLocationFollowing(true); followingLocation=true; } }; private final ActivityResultLauncher positionRequestLauncher = registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result -> { if (result == null){ Log.w(DEBUG_TAG, "Got asked permission but request is null, doing nothing?"); } else if(Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)) && Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION))){ map.getOverlays().remove(mLocationOverlay); startLocationOverlay(true, map); if(getContext()==null || getContext().getSystemService(Context.LOCATION_SERVICE)==null) return; LocationManager locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE); @SuppressLint("MissingPermission") Location userLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); if (userLocation != null) { map.getController().setZoom(POSITION_FOUND_ZOOM); GeoPoint startPoint = new GeoPoint(userLocation); setLocationFollowing(true); map.getController().setCenter(startPoint); } } else Log.w(DEBUG_TAG,"No location permission"); }); public MapFragment() { } public static MapFragment getInstance(){ return new MapFragment(); } public static MapFragment getInstance(@NonNull Stop stop){ MapFragment fragment= new MapFragment(); Bundle args = new Bundle(); args.putDouble(BUNDLE_LATIT, stop.getLatitude()); args.putDouble(BUNDLE_LONGIT, stop.getLongitude()); args.putString(BUNDLE_NAME, stop.getStopDisplayName()); args.putString(BUNDLE_ID, stop.ID); args.putString(BUNDLE_ROUTES_STOPPING, stop.routesThatStopHereToString()); fragment.setArguments(args); return fragment; } //public static MapFragment getInstance(@NonNull Stop stop){ // return getInstance(stop.getLatitude(), stop.getLongitude(), stop.getStopDisplayName(), stop.ID); //} @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { //use the same layout as the activity View root = inflater.inflate(R.layout.fragment_map, container, false); if (getContext() == null){ throw new IllegalStateException(); } ctx = getContext().getApplicationContext(); Configuration.getInstance().load(ctx, PreferenceManager.getDefaultSharedPreferences(ctx)); map = root.findViewById(R.id.map); map.setTileSource(TileSourceFactory.MAPNIK); //map.setTilesScaledToDpi(true); map.setFlingEnabled(true); // add ability to zoom with 2 fingers map.setMultiTouchControls(true); btCenterMap = root.findViewById(R.id.icon_center_map); btFollowMe = root.findViewById(R.id.icon_follow); coordLayout = root.findViewById(R.id.coord_layout); //setup FolderOverlay stopsFolderOverlay = new FolderOverlay(); //setup Bus Markers Overlay busPositionsOverlay = new FolderOverlay(); //reset shown bus updates busPositionMarkersByTrip.clear(); tripMarkersAnimators.clear(); //set map not done hasMapStartFinished = false; String keySourcePositions=getString(R.string.pref_positions_source); useMQTTViewModel = ( PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions,LIVE_POSITIONS_PREF_MQTT_VALUE).contentEquals(LIVE_POSITIONS_PREF_MQTT_VALUE)); //Start map from bundle if (savedInstanceState !=null) startMap(getArguments(), savedInstanceState); else startMap(getArguments(), savedMapState); //set listeners map.addMapListener(new DelayedMapListener(new MapListener() { @Override public boolean onScroll(ScrollEvent paramScrollEvent) { requestStopsToShow(); //Log.d(DEBUG_TAG, "Scrolling"); //if (moveTriggeredByCode) moveTriggeredByCode =false; //else setLocationFollowing(false); return true; } @Override public boolean onZoom(ZoomEvent event) { requestStopsToShow(); return true; } })); btCenterMap.setOnClickListener(v -> { //Log.i(TAG, "centerMap clicked "); if(Permissions.bothLocationPermissionsGranted(getContext())) { final GeoPoint myPosition = mLocationOverlay.getMyLocation(); map.getController().animateTo(myPosition); } else Toast.makeText(getContext(), R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show(); }); btFollowMe.setOnClickListener(v -> { //Log.i(TAG, "btFollowMe clicked "); if(Permissions.bothLocationPermissionsGranted(getContext())) setLocationFollowing(!followingLocation); else Toast.makeText(getContext(), R.string.enable_position_message_map, Toast.LENGTH_SHORT) .show(); }); return root; } @Override public void onAttach(@NonNull Context context) { super.onAttach(context); ViewModelProvider provider = new ViewModelProvider(this); //gtfsPosViewModel = provider.get(GtfsPositionsViewModel.class); livePositionsViewModel = provider.get(LivePositionsViewModel.class); stopsViewModel = provider.get(StopsMapViewModel.class); if (context instanceof FragmentListenerMain) { listenerMain = (FragmentListenerMain) context; } else { throw new RuntimeException(context.toString() + " must implement FragmentListenerMain"); } } @Override public void onDetach() { super.onDetach(); listenerMain = null; //stop animations // setupOnAttached = true; Log.w(DEBUG_TAG, "Fragment detached"); } @Override public void onPause() { super.onPause(); Log.w(DEBUG_TAG, "On pause called mapfrag"); saveMapState(); for (ObjectAnimator animator : tripMarkersAnimators.values()) { if(animator!=null && animator.isRunning()){ animator.cancel(); } } tripMarkersAnimators.clear(); if(useMQTTViewModel) livePositionsViewModel.stopMatoUpdates(); } /** * Save the map state inside the fragment * (calls saveMapState(bundle)) */ private void saveMapState(){ savedMapState = new Bundle(); saveMapState(savedMapState); } /** * Save the state of the map to restore it to a later time * @param bundle the bundle in which to save the data */ private void saveMapState(Bundle bundle){ Log.d(DEBUG_TAG, "Saving state, location following: "+followingLocation); bundle.putBoolean(FOLLOWING_LOCAT_KEY, followingLocation); if (map == null){ //The map is null, it can happen? Log.e(DEBUG_TAG, "Cannot save map center, map is null"); return; } final IGeoPoint loc = map.getMapCenter(); bundle.putDouble(MAP_CENTER_LAT_KEY, loc.getLatitude()); bundle.putDouble(MAP_CENTER_LON_KEY, loc.getLongitude()); bundle.putDouble(MAP_CURRENT_ZOOM_KEY, map.getZoomLevelDouble()); } @Override public void onResume() { super.onResume(); //TODO: cleanup duplicate code (maybe merging the positions classes?) if(listenerMain!=null) listenerMain.readyGUIfor(FragmentKind.MAP); /// choose which to use String keySourcePositions=getString(R.string.pref_positions_source); useMQTTViewModel = PreferenceManager.getDefaultSharedPreferences(requireContext()) .getString(keySourcePositions,LIVE_POSITIONS_PREF_MQTT_VALUE).contentEquals( LIVE_POSITIONS_PREF_MQTT_VALUE); if(livePositionsViewModel !=null) { //gtfsPosViewModel.requestUpdates(); if(useMQTTViewModel) livePositionsViewModel.requestMatoPosUpdates(MQTTMatoClient.LINES_ALL); else livePositionsViewModel.requestGTFSUpdates(); //mapViewModel.testCascade(); + livePositionsViewModel.isLastWorkResultGood().observe(this, d -> + Log.d(DEBUG_TAG, "Last trip download result is "+d)); livePositionsViewModel.getTripsGtfsIDsToQuery().observe(this, dat -> { Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: "+dat); - //gtfsPosViewModel.downloadTripsFromMato(dat); - MatoTripsDownloadWorker.Companion.downloadTripsFromMato(dat, + livePositionsViewModel.downloadTripsFromMato(dat); + /*MatoTripsDownloadWorker.Companion.requestMatoTripsDownload(dat, requireContext().getApplicationContext(), "BusTO-MatoTripDownload"); + + */ }); } /*else if(gtfsPosViewModel!=null){ gtfsPosViewModel.requestUpdates(); gtfsPosViewModel.getTripsGtfsIDsToQuery().observe(this, dat -> { Log.i(DEBUG_TAG, "Have these trips IDs missing from the DB, to be queried: "+dat); //gtfsPosViewModel.downloadTripsFromMato(dat); MatoTripsDownloadWorker.Companion.downloadTripsFromMato(dat,getContext().getApplicationContext(), "BusTO-MatoTripDownload"); }); } */ else Log.e(DEBUG_TAG, "livePositionsViewModel is null at onResume"); //rerequest stop stopsViewModel.requestStopsInBoundingBox(map.getBoundingBox()); } private void startRequestsPositions(){ if (livePositionsViewModel != null) { //should always be the case livePositionsViewModel.getUpdatesWithTripAndPatterns().observe(getViewLifecycleOwner(), data -> { Log.d(DEBUG_TAG, "Have " + data.size() + " trip updates, has Map start finished: " + hasMapStartFinished); if (hasMapStartFinished) updateBusPositionsInMap(data); if(!isDetached() && !useMQTTViewModel) livePositionsViewModel.requestDelayedGTFSUpdates(3000); }); } else { Log.e(DEBUG_TAG, "PositionsViewModel is null"); } } @Override public void onSaveInstanceState(@NonNull Bundle outState) { saveMapState(outState); super.onSaveInstanceState(outState); } //own methods /** * Switch following the location on and off * @param value true if we want to follow location */ public void setLocationFollowing(Boolean value){ followingLocation = value; if(mLocationOverlay==null || getContext() == null || map ==null) //nothing else to do return; if (value){ mLocationOverlay.enableFollowLocation(); } else { mLocationOverlay.disableFollowLocation(); } } /** * Do all the stuff you need to do on the gui, when parameter is changed to value * @param following value */ protected void updateGUIForLocationFollowing(boolean following){ if (following) btFollowMe.setImageResource(R.drawable.ic_follow_me_on); else btFollowMe.setImageResource(R.drawable.ic_follow_me); } /** * Build the location overlay. Enable only when * a) we know we have the permission * b) the location map is set */ private void startLocationOverlay(boolean enableLocation, MapView map){ if(getActivity()== null) throw new IllegalStateException("Cannot enable LocationOverlay now"); // Location Overlay // from OpenBikeSharing (THANK GOD) Log.d(DEBUG_TAG, "Starting position overlay"); GpsMyLocationProvider imlp = new GpsMyLocationProvider(getActivity().getBaseContext()); imlp.setLocationUpdateMinDistance(5); imlp.setLocationUpdateMinTime(2000); final LocationOverlay overlay = new LocationOverlay(imlp,map, locationCallbacks); if (enableLocation) overlay.enableMyLocation(); overlay.setOptionsMenuEnabled(true); //map.getOverlays().add(this.mLocationOverlay); this.mLocationOverlay = overlay; map.getOverlays().add(mLocationOverlay); } public void startMap(Bundle incoming, Bundle savedInstanceState) { //Check that we're attached GeneralActivity activity = getActivity() instanceof GeneralActivity ? (GeneralActivity) getActivity() : null; if(getContext()==null|| activity==null){ //we are not attached Log.e(DEBUG_TAG, "Calling startMap when not attached"); return; }else{ Log.d(DEBUG_TAG, "Starting map from scratch"); } //clear previous overlays map.getOverlays().clear(); //parse incoming bundle GeoPoint marker = null; String name = null; String ID = null; String routesStopping = ""; if (incoming != null) { double lat = incoming.getDouble(BUNDLE_LATIT); double lon = incoming.getDouble(BUNDLE_LONGIT); marker = new GeoPoint(lat, lon); name = incoming.getString(BUNDLE_NAME); ID = incoming.getString(BUNDLE_ID); routesStopping = incoming.getString(BUNDLE_ROUTES_STOPPING, ""); } //ask for location permission if(!Permissions.bothLocationPermissionsGranted(activity)){ 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); } shownStops = new HashSet<>(); // move the map on the marker position or on a default view point: Turin, Piazza Castello // and set the start zoom IMapController mapController = map.getController(); GeoPoint startPoint = null; startLocationOverlay(Permissions.bothLocationPermissionsGranted(activity), map); // set the center point if (marker != null) { //startPoint = marker; mapController.setZoom(POSITION_FOUND_ZOOM); setLocationFollowing(false); // put the center a little bit off (animate later) startPoint = new GeoPoint(marker); startPoint.setLatitude(marker.getLatitude()+ utils.angleRawDifferenceFromMeters(20)); startPoint.setLongitude(marker.getLongitude()-utils.angleRawDifferenceFromMeters(20)); //don't need to do all the rest since we want to show a point } else if (savedInstanceState != null && savedInstanceState.containsKey(MAP_CURRENT_ZOOM_KEY)) { mapController.setZoom(savedInstanceState.getDouble(MAP_CURRENT_ZOOM_KEY)); mapController.setCenter(new GeoPoint(savedInstanceState.getDouble(MAP_CENTER_LAT_KEY), savedInstanceState.getDouble(MAP_CENTER_LON_KEY))); Log.d(DEBUG_TAG, "Location following from savedInstanceState: "+savedInstanceState.getBoolean(FOLLOWING_LOCAT_KEY)); setLocationFollowing(savedInstanceState.getBoolean(FOLLOWING_LOCAT_KEY)); } else { Log.d(DEBUG_TAG, "No position found from intent or saved state"); boolean found = false; LocationManager locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE); //check for permission if (locationManager != null && Permissions.bothLocationPermissionsGranted(activity)) { @SuppressLint("MissingPermission") Location userLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER); if (userLocation != null) { double distan = utils.measuredistanceBetween(userLocation.getLatitude(), userLocation.getLongitude(), DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON); if (distan < 100_000.0) { mapController.setZoom(POSITION_FOUND_ZOOM); startPoint = new GeoPoint(userLocation); found = true; setLocationFollowing(true); } } } if(!found){ startPoint = new GeoPoint(DEFAULT_CENTER_LAT, DEFAULT_CENTER_LON); mapController.setZoom(NO_POSITION_ZOOM); setLocationFollowing(false); } } // set the minimum zoom level map.setMinZoomLevel(15.0); //add contingency check (shouldn't happen..., but) if (startPoint != null) { mapController.setCenter(startPoint); } //add stops overlay //map.getOverlays().add(mLocationOverlay); map.getOverlays().add(this.stopsFolderOverlay); Log.d(DEBUG_TAG, "Requesting stops load"); // This is not necessary, by setting the center we already move // the map and we trigger a stop request //requestStopsToShow(); if (marker != null) { // make a marker with the info window open for the searched marker //TODO: make Stop Bundle-able Marker stopMarker = makeMarker(marker, ID , name, routesStopping,true); map.getController().animateTo(marker); } //add the overlays with the bus stops if(busPositionsOverlay == null){ //Log.i(DEBUG_TAG, "Null bus positions overlay,redo"); busPositionsOverlay = new FolderOverlay(); } startRequestsPositions(); if(stopsViewModel !=null){ stopsViewModel.getStopsInBoundingBox().observe(getViewLifecycleOwner(), this::showStopsMarkers ); } else Log.d(DEBUG_TAG, "Cannot observe new stops in map, stopsViewModel is null"); map.getOverlays().add(this.busPositionsOverlay); //set map as started hasMapStartFinished = true; } /** * Start a request to load the stops that are in the current view * from the database */ private void requestStopsToShow(){ // get the top, bottom, left and right screen's coordinate BoundingBox bb = map.getBoundingBox(); Log.d(DEBUG_TAG, "Requesting stops in bounding box, stopViewModel is null "+(stopsViewModel==null)); if(stopsViewModel!=null){ stopsViewModel.requestStopsInBoundingBox(bb); } /*double latFrom = bb.getLatSouth(); double latTo = bb.getLatNorth(); double lngFrom = bb.getLonWest(); double lngTo = bb.getLonEast(); if (stopFetcher!= null && stopFetcher.getStatus()!= AsyncTask.Status.FINISHED) stopFetcher.cancel(true); stopFetcher = new AsyncStopFetcher(this); stopFetcher.execute( new AsyncStopFetcher.BoundingBoxLimit(lngFrom,lngTo,latFrom, latTo)); */ } private void updateBusMarker(final Marker marker, final LivePositionUpdate posUpdate, @Nullable boolean justCreated){ GeoPoint position; final String updateID = posUpdate.getTripID(); if(!justCreated){ position = marker.getPosition(); if(posUpdate.getLatitude()!=position.getLatitude() || posUpdate.getLongitude()!=position.getLongitude()){ GeoPoint newpos = new GeoPoint(posUpdate.getLatitude(), posUpdate.getLongitude()); ObjectAnimator valueAnimator = MarkerUtils.makeMarkerAnimator( map, marker, newpos, MarkerUtils.LINEAR_ANIMATION, 1200); valueAnimator.setAutoCancel(true); tripMarkersAnimators.put(updateID,valueAnimator); valueAnimator.start(); } //marker.setPosition(new GeoPoint(posUpdate.getLatitude(), posUpdate.getLongitude())); } else { position = new GeoPoint(posUpdate.getLatitude(), posUpdate.getLongitude()); marker.setPosition(position); } if(posUpdate.getBearing()!=null) marker.setRotation(posUpdate.getBearing()*(-1.f)); } private void updateBusPositionsInMap(HashMap> tripsPatterns){ Log.d(DEBUG_TAG, "Updating positions of the buses"); //if(busPositionsOverlay == null) busPositionsOverlay = new FolderOverlay(); final ArrayList noPatternsTrips = new ArrayList<>(); for(String tripID: tripsPatterns.keySet()) { final Pair pair = tripsPatterns.get(tripID); if (pair == null) continue; final LivePositionUpdate update = pair.getFirst(); final TripAndPatternWithStops tripWithPatternStops = pair.getSecond(); //check if Marker is already created if (busPositionMarkersByTrip.containsKey(tripID)){ //need to change the position of the marker final Marker marker = busPositionMarkersByTrip.get(tripID); assert marker!=null; updateBusMarker(marker, update, false); if(marker.getInfoWindow()!=null && marker.getInfoWindow() instanceof BusInfoWindow){ BusInfoWindow window = (BusInfoWindow) marker.getInfoWindow(); if(tripWithPatternStops != null) { //Log.d(DEBUG_TAG, "Update pattern for trip: "+tripID); window.setPatternAndDraw(tripWithPatternStops.getPattern()); } } } else{ //marker is not there, need to make it if(map==null) Log.e(DEBUG_TAG, "Creating marker with null map, things will explode"); final Marker marker = new Marker(map); /*final Drawable mDrawable = DrawableUtils.Companion.getScaledDrawableResources( getResources(), R.drawable.point_heading_icon, R.dimen.map_icons_size, R.dimen.map_icons_size); */ //String route = GtfsUtils.getLineNameFromGtfsID(update.getRouteID()); final Drawable mdraw = ResourcesCompat.getDrawable(getResources(),R.drawable.map_bus_position_icon, null); /*final Drawable mdraw = DrawableUtils.Companion.writeOnDrawable(getResources(), R.drawable.point_heading_icon, R.color.white, route,12); */ assert mdraw != null; //mdraw.setBounds(0,0,28,28); marker.setIcon(mdraw); if(tripWithPatternStops == null){ noPatternsTrips.add(tripID); } MatoPattern markerPattern = null; if(tripWithPatternStops != null && tripWithPatternStops.getPattern()!=null) markerPattern = tripWithPatternStops.getPattern(); marker.setInfoWindow(new BusInfoWindow(map, update, markerPattern , false, (pattern) -> { })); marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); updateBusMarker(marker, update, true); // the overlay is null when it's not attached yet?5 // cannot recreate it because it becomes null very soon // if(busPositionsOverlay == null) busPositionsOverlay = new FolderOverlay(); //save the marker if(busPositionsOverlay!=null) { busPositionsOverlay.add(marker); busPositionMarkersByTrip.put(tripID, marker); } } } if(noPatternsTrips.size()>0){ Log.i(DEBUG_TAG, "These trips have no matching pattern: "+noPatternsTrips); } } /** * Add stops as Markers on the map * @param stops the list of stops that must be included */ protected void showStopsMarkers(List stops){ if (getContext() == null || stops == null){ //we are not attached return; } boolean good = true; for (Stop stop : stops) { if (shownStops.contains(stop.ID)){ continue; } if(stop.getLongitude()==null || stop.getLatitude()==null) continue; shownStops.add(stop.ID); if(!map.isShown()){ if(good) Log.d(DEBUG_TAG, "Need to show stop but map is not shown, probably detached already"); good = false; continue; } else if(map.getRepository() == null){ Log.e(DEBUG_TAG, "Map view repository is null"); } GeoPoint marker = new GeoPoint(stop.getLatitude(), stop.getLongitude()); Marker stopMarker = makeMarker(marker, stop, false); stopsFolderOverlay.add(stopMarker); if (!map.getOverlays().contains(stopsFolderOverlay)) { Log.w(DEBUG_TAG, "Map doesn't have folder overlay"); } good=true; } //Log.d(DEBUG_TAG,"We have " +stopsFolderOverlay.getItems().size()+" stops in the folderOverlay"); //force redraw of markers map.invalidate(); } public Marker makeMarker(GeoPoint geoPoint, Stop stop, boolean isStartMarker){ return makeMarker(geoPoint,stop.ID, stop.getStopDefaultName(), stop.routesThatStopHereToString(), isStartMarker); } public Marker makeMarker(GeoPoint geoPoint, String stopID, String stopName, String routesStopping, boolean isStartMarker) { // add a marker final Marker marker = new Marker(map); // set custom info window as info window CustomInfoWindow popup = new CustomInfoWindow(map, stopID, stopName, routesStopping, responder, R.layout.linedetail_stop_infowindow, R.color.red_darker); marker.setInfoWindow(popup); // make the marker clickable marker.setOnMarkerClickListener((thisMarker, mapView) -> { if (thisMarker.isInfoWindowOpen()) { // on second click Log.w(DEBUG_TAG, "Pressed on the click marker"); } else { // on first click // hide all opened info window InfoWindow.closeAllInfoWindowsOn(map); // show this particular info window thisMarker.showInfoWindow(); // move the map to its position map.getController().animateTo(thisMarker.getPosition()); } return true; }); // set its position marker.setPosition(geoPoint); marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); // add to it an icon //marker.setIcon(getResources().getDrawable(R.drawable.bus_marker)); marker.setIcon(ResourcesCompat.getDrawable(getResources(), R.drawable.bus_stop, ctx.getTheme())); // add to it a title marker.setTitle(stopName); // set the description as the ID marker.setSnippet(stopID); // show popup info window of the searched marker if (isStartMarker) { marker.showInfoWindow(); //map.getController().animateTo(marker.getPosition()); } return marker; } @Nullable @Override public View getBaseViewForSnackBar() { return coordLayout; } } diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/GtfsPositionsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/GtfsPositionsViewModel.kt index 5b1e26b..aa195cf 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/GtfsPositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/GtfsPositionsViewModel.kt @@ -1,202 +1,203 @@ /* BusTO - View Models components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* +import androidx.work.OneTimeWorkRequest import com.android.volley.Response import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest import it.reyboz.bustorino.data.* import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * View Model for the map. For containing the stops, the trips and whatever */ class GtfsPositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) private val netVolleyManager = NetworkVolleyManager.getInstance(application) val positionsLiveData = MutableLiveData>() private val positionsRequestRunning = MutableLiveData() private val positionRequestListener = object: GtfsRtPositionsRequest.Companion.RequestListener{ override fun onResponse(response: ArrayList?) { Log.i(DEBUG_TI,"Got response from the GTFS RT server") response?.let {it:ArrayList -> if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the server") return } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { positionsLiveData.postValue(it) } } } //whatever the result, launch again the update TODO } } private val positionRequestErrorListener = Response.ErrorListener { Log.e(DEBUG_TI, "Could not download the update, error:\n"+it.stackTrace) } fun requestUpdates(){ if(positionsRequestRunning.value == null || !positionsRequestRunning.value!!) { val request = GtfsRtPositionsRequest(positionRequestErrorListener, positionRequestListener) netVolleyManager.requestQueue.add(request) Log.i(DEBUG_TI, "Requested GTFS realtime position updates") positionsRequestRunning.value = true } } /*suspend fun requestDelayedUpdates(timems: Long){ delay(timems) requestUpdates() } */ fun requestDelayedUpdates(timems: Long){ viewModelScope.launch { delay(timems) requestUpdates() } } // TRIPS IDS that have to be queried to the DB val tripsIDsInUpdates : LiveData> = positionsLiveData.map { Log.i(DEBUG_TI, "positionsLiveData changed") //allow new requests for the positions of buses positionsRequestRunning.value = false //add "gtt:" prefix because it's implicit in GTFS Realtime API return@map it.map { pos -> "gtt:"+pos.tripID } } //this holds the trips that have been downloaded but for which we have no pattern /*private val gtfsTripsInDBMissingPattern = tripsIDsInUpdates.map { tripsIDs -> val tripsInDB = gtfsRepo.gtfsDao.getTripsFromIDs(tripsIDs) val tripsPatternCodes = tripsInDB.map { tr -> tr.patternId } val codesInDB = gtfsRepo.gtfsDao.getPatternsCodesInTheDB(tripsPatternCodes) tripsInDB.filter { !(codesInDB.contains(it.patternId)) } }*/ //private val patternsCodesInDB = tripsDBPatterns.map { gtfsRepo.gtfsDao.getPatternsCodesInTheDB(it) } // trips that are in the DB, together with the pattern. If the pattern is not present in the DB, it's null val gtfsTripsPatternsInDB = tripsIDsInUpdates.switchMap { //Log.i(DEBUG_TI, "tripsIds in updates changed: ${it.size}") gtfsRepo.gtfsDao.getTripPatternStops(it) } //trip IDs to query, which are not present in the DB val tripsGtfsIDsToQuery: LiveData> = gtfsTripsPatternsInDB.map { tripswithPatterns -> val tripNames=tripswithPatterns.map { twp-> twp.trip.tripID } Log.i(DEBUG_TI, "Have ${tripswithPatterns.size} trips in the DB") if (tripsIDsInUpdates.value!=null) return@map tripsIDsInUpdates.value!!.filter { !tripNames.contains(it) } else { Log.e(DEBUG_TI,"Got results for gtfsTripsInDB but not tripsIDsInUpdates??") return@map ArrayList() } } val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> Log.i(DEBUG_TI, "Mapping trips and patterns") val mdict = HashMap>() //missing patterns val routesToDownload = HashSet() if(positionsLiveData.value!=null) for(update in positionsLiveData.value!!){ val trID = update.tripID var found = false for(trip in tripPatterns){ if (trip.pattern == null){ //pattern is null, which means we have to download // the pattern data from MaTO routesToDownload.add(trip.trip.routeID) } if (trip.trip.tripID == "gtt:$trID"){ found = true //insert directly mdict[trID] = Pair(update,trip) break } } if (!found){ //Log.d(DEBUG_TI, "Cannot find pattern ${tr}") //give the update anyway mdict[trID] = Pair(update,null) } } //have to request download of missing Patterns if (routesToDownload.size > 0){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") downloadMissingPatterns(ArrayList(routesToDownload)) } return@map mdict } /* There are two strategies for the queries, since we cannot query a bunch of tripIDs all together to Mato API -> we need to query each separately: 1 -> wait until they are all queried to insert in the DB 2 -> after each request finishes, insert it into the DB Keep in mind that trips DO CHANGE often, and so do the Patterns */ - fun downloadTripsFromMato(trips: List): Boolean{ - return MatoTripsDownloadWorker.downloadTripsFromMato(trips,getApplication(), DEBUG_TI) + fun downloadTripsFromMato(trips: List): OneTimeWorkRequest?{ + return MatoTripsDownloadWorker.requestMatoTripsDownload(trips,getApplication(), "BusTO-MatoTripsDown") } private fun downloadMissingPatterns(routeIds: List): Boolean{ return MatoPatternsDownloadWorker.downloadPatternsForRoutes(routeIds, getApplication()) } init { Log.d(DEBUG_TI, "MapViewModel created") Log.d(DEBUG_TI, "Observers of positionsLiveData ${positionsLiveData.hasActiveObservers()}") positionsRequestRunning.value = false; } fun testCascade(){ val n = ArrayList() n.add(LivePositionUpdate("22920721U","lala","lalal","lol","ASD", 1000.0,1000.0, 9000.0f, 21838191, null )) positionsLiveData.value = n } /** * Start downloading missing GtfsTrips and Insert them in the DB */ companion object{ private const val DEBUG_TI="BusTO-GTFSRTViewModel" const val DEFAULT_DELAY_REQUESTS: Long=4000 } } \ No newline at end of file diff --git a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt index 53c6e51..27f3df6 100644 --- a/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt +++ b/app/src/main/java/it/reyboz/bustorino/viewmodels/LivePositionsViewModel.kt @@ -1,225 +1,287 @@ /* BusTO - ViewModel components Copyright (C) 2023 Fabio Mazza This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package it.reyboz.bustorino.viewmodels import android.app.Application import android.util.Log import androidx.lifecycle.* +import androidx.work.WorkInfo +import androidx.work.WorkManager import com.android.volley.DefaultRetryPolicy import com.android.volley.Response import it.reyboz.bustorino.backend.NetworkVolleyManager import it.reyboz.bustorino.backend.gtfs.GtfsRtPositionsRequest import it.reyboz.bustorino.backend.gtfs.LivePositionUpdate import it.reyboz.bustorino.backend.mato.MQTTMatoClient import it.reyboz.bustorino.data.GtfsRepository import it.reyboz.bustorino.data.MatoPatternsDownloadWorker +import it.reyboz.bustorino.data.MatoTripsDownloadWorker import it.reyboz.bustorino.data.gtfs.TripAndPatternWithStops import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.* +import kotlin.collections.ArrayList +import kotlin.collections.HashMap +import kotlin.collections.HashSet class LivePositionsViewModel(application: Application): AndroidViewModel(application) { private val gtfsRepo = GtfsRepository(application) //private val updates = UpdatesMap() private val updatesLiveData = MutableLiveData>() private val netVolleyManager = NetworkVolleyManager.getInstance(application) private var mqttClient = MQTTMatoClient() private var lineListening = "" private var lastTimeReceived: Long = 0 private val gtfsRtRequestRunning = MutableLiveData(false) + private val lastFailedTripsRequest = HashMap() + private val workManager = WorkManager.getInstance(application) + + private var lastRequestedDownloadTrips = MutableLiveData>() + + var isLastWorkResultGood = workManager + .getWorkInfosForUniqueWorkLiveData(MatoTripsDownloadWorker.TAG_TRIPS).map { it -> + if (it.isEmpty()) return@map false + var res = true + if(it[0].state == WorkInfo.State.FAILED){ + val currDate = Date() + res = false + lastRequestedDownloadTrips.value?.let { trips-> + for(tr in trips){ + lastFailedTripsRequest[tr] = currDate + } + } + + } + return@map res + } /** * Responder to the MQTT Client */ private val matoPositionListener = MQTTMatoClient.Companion.MQTTMatoListener{ val mupds = ArrayList() if(lineListening==MQTTMatoClient.LINES_ALL){ for(sdic in it.values){ for(update in sdic.values){ mupds.add(update) } } } else{ //we're listening to one if (it.containsKey(lineListening.trim()) ){ for(up in it[lineListening]?.values!!){ mupds.add(up) } } } val time = System.currentTimeMillis() if(lastTimeReceived == (0.toLong()) || (time-lastTimeReceived)>500){ updatesLiveData.postValue(mupds) lastTimeReceived = time } } //find the trip IDs in the updates private val tripsIDsInUpdates = updatesLiveData.map { it -> //Log.d(DEBUG_TI, "Updates map has keys ${upMap.keys}") it.map { pos -> "gtt:"+pos.tripID } } // get the trip IDs in the DB private val gtfsTripsPatternsInDB = tripsIDsInUpdates.switchMap { //Log.i(DEBUG_TI, "tripsIds in updates: ${it.size}") gtfsRepo.gtfsDao.getTripPatternStops(it) } //trip IDs to query, which are not present in the DB //REMEMBER TO OBSERVE THIS IN THE MAP val tripsGtfsIDsToQuery: LiveData> = gtfsTripsPatternsInDB.map { tripswithPatterns -> val tripNames=tripswithPatterns.map { twp-> twp.trip.tripID } Log.i(DEBUG_TI, "Have ${tripswithPatterns.size} trips in the DB") if (tripsIDsInUpdates.value!=null) return@map tripsIDsInUpdates.value!!.filter { !(tripNames.contains(it) || it.contains("null"))} else { Log.e(DEBUG_TI,"Got results for gtfsTripsInDB but not tripsIDsInUpdates??") return@map ArrayList() } } // unify trips with updates val updatesWithTripAndPatterns = gtfsTripsPatternsInDB.map { tripPatterns-> Log.i(DEBUG_TI, "Mapping trips and patterns") val mdict = HashMap>() //missing patterns val routesToDownload = HashSet() if(updatesLiveData.value!=null) for(update in updatesLiveData.value!!){ val trID:String = update.tripID var found = false for(trip in tripPatterns){ if (trip.pattern == null){ //pattern is null, which means we have to download // the pattern data from MaTO routesToDownload.add(trip.trip.routeID) } if (trip.trip.tripID == "gtt:$trID"){ found = true //insert directly mdict[trID] = Pair(update,trip) break } } if (!found){ //Log.d(DEBUG_TI, "Cannot find pattern ${tr}") //give the update anyway mdict[trID] = Pair(update,null) } } //have to request download of missing Patterns if (routesToDownload.size > 0){ Log.d(DEBUG_TI, "Have ${routesToDownload.size} missing patterns from the DB: $routesToDownload") //downloadMissingPatterns (ArrayList(routesToDownload)) MatoPatternsDownloadWorker.downloadPatternsForRoutes(routesToDownload.toList(), getApplication()) } return@map mdict } fun requestMatoPosUpdates(line: String){ lineListening = line viewModelScope.launch { mqttClient.startAndSubscribe(line,matoPositionListener, getApplication()) } //updatePositions(1000) } fun stopMatoUpdates(){ viewModelScope.launch { val tt = System.currentTimeMillis() mqttClient.stopMatoRequests(matoPositionListener) val time = System.currentTimeMillis() -tt Log.d(DEBUG_TI, "Took $time ms to unsubscribe") } } fun retriggerPositionUpdate(){ if(updatesLiveData.value!=null){ updatesLiveData.postValue(updatesLiveData.value) } } //Gtfs Real time private val gtfsPositionsReqListener = object: GtfsRtPositionsRequest.Companion.RequestListener{ override fun onResponse(response: ArrayList?) { Log.i(DEBUG_TI,"Got response from the GTFS RT server") response?.let {it:ArrayList -> if (it.size == 0) { Log.w(DEBUG_TI,"No position updates from the GTFS RT server") return } else { //Log.i(DEBUG_TI, "Posting value to positionsLiveData") viewModelScope.launch { updatesLiveData.postValue(it) } } } gtfsRtRequestRunning.postValue(false) } } private val positionRequestErrorListener = Response.ErrorListener { Log.e(DEBUG_TI, "Could not download the update", it) gtfsRtRequestRunning.postValue(false) } fun requestGTFSUpdates(){ if(gtfsRtRequestRunning.value == null || !gtfsRtRequestRunning.value!!) { val request = GtfsRtPositionsRequest(positionRequestErrorListener, gtfsPositionsReqListener) request.setRetryPolicy( DefaultRetryPolicy(1000,10,DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) ) netVolleyManager.requestQueue.add(request) Log.i(DEBUG_TI, "Requested GTFS realtime position updates") gtfsRtRequestRunning.value = true } } fun requestDelayedGTFSUpdates(timems: Long){ viewModelScope.launch { delay(timems) requestGTFSUpdates() } } override fun onCleared() { //stop the MQTT Service Log.d(DEBUG_TI, "Clearing the live positions view model, stopping the mqttClient") mqttClient.disconnect() super.onCleared() } + //Request trips download + fun downloadTripsFromMato(trips: List): Boolean{ + if(trips.isEmpty()) + return false + var shouldContinue = false + val currentDateTime = Date().time + + for (tr in trips){ + if (!lastFailedTripsRequest.containsKey(tr)){ + shouldContinue = true + break + } else{ + //Log.i(DEBUG_TI, "Last time the trip has failed is ${lastFailedTripsRequest[tr]}") + if ((lastFailedTripsRequest[tr]!!.time - currentDateTime) > MAX_TIME_RETRY){ + shouldContinue =true + break + } + } + } + if (shouldContinue) { + //if one trip + val workRequ =MatoTripsDownloadWorker.requestMatoTripsDownload(trips, getApplication(), "BusTO-MatoTripsDown") + workRequ?.let { req -> + Log.d(DEBUG_TI, "Enqueueing new work, saving work info") + lastRequestedDownloadTrips.postValue(trips) + //isLastWorkResultGood = + } + } else{ + Log.w(DEBUG_TI, "Requested to fetch data for ${trips.size} trips but they all have failed before in the last $MAX_MINUTES_RETRY mins") + } + return shouldContinue + } companion object{ private const val DEBUG_TI = "BusTO-LivePosViewModel" + private const val MAX_MINUTES_RETRY = 3 + private const val MAX_TIME_RETRY = MAX_MINUTES_RETRY*60*1000 //3 minutes (in milliseconds) } } \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 63cf4f2..f8e5690 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,291 +1,296 @@ Stai utilizzando l\'ultimo ritrovato in materia di rispetto della tua privacy. Cerca QR Code Si No Prossimo Precedente Installare Barcode Scanner? Questa azione richiede un\'altra app per scansionare i codici QR. Vuoi installare Barcode Scanner? Numero fermata Nome fermata Inserisci il numero della fermata Inserisci il nome della fermata Verifica l\'accesso ad Internet! Sembra che nessuna fermata abbia questo nome Nessun passaggio trovato alla fermata Errore di lettura del sito 5T/GTT (dannato sito!) Fermata: %1$s Linea Linee Linee urbane Linee extraurbane Linee turistiche Direzione: Linea: %1$s Linee: %1$s Scegli la fermata… Nessun passaggio Nessun QR code trovato, prova ad usare un\'altra app Preferiti Aiuto Informazioni Più informazioni Contribuisci https://gitpull.it/w/librebusto/it/ Codice sorgente Licenza Incontra l\'autore Fermata aggiunta ai preferiti Impossibile aggiungere ai preferiti (memoria piena o database corrotto?)! Preferiti Mappa Nessun preferito? Arghh!\nSchiaccia sulla stella di una fermata per aggiungerla a questa lista! Rimuovi Rinomina Rinomina fermata Reset Informazioni Tocca la stella per aggiungere la fermata ai preferiti\n\nCome leggere gli orari: \n   12:56* Orario in tempo reale\n   12:56   Orario programmato\n\nTrascina giù per aggiornare l\'orario. \nTocca a lungo su Fonte Orari per cambiare sorgente degli orari di arrivo. OK! Benvenuto!

Grazie per aver scelto BusTO, un\'app indipendente da GTT/5T, per spostarsi a Torino attraverso software libero:


Perché usare BusTO?

- Non sei monitorato
- Non ci sono pubblicità
- La tua privacy è al sicuro
- Inoltre l\'app è molto leggera!


Come funziona?

Quest\'app ottiene i passaggi dei bus, le fermate e altre informazioni utili unendo dati forniti dal sito www.gtt.to.it, www.5t.torino.it, muoversiatorino.it "per uso personale" e altre fonti Open Data (aperto.comune.torino.it).


Ingredienti:
- Fabio Mazza attuale rockstar developer anziano.
- Andrea Ugo attuale rockstar developer in formazione.
- Silviu Chiriac designer del logo 2021.
- Marco M formidabile tester e cacciatore di bug.
- Ludovico Pavesi ex rockstar developer anziano asd.
- Valerio Bozzolan attuale manutentore.
- Marco Gagino apprezzato ex collaboratore, ideatore icona e grafica.
- JSoup libreria per "web scaping".
- Google icone e libreria di supporto per il Material Design.
- Tutti i contributori e i beta tester!


Licenze

L\'app e il relativo codice sorgente sono distribuiti sotto la licenza GNU General Public License v3+. Ciò significa che puoi usare, studiare, migliorare e ricondividere quest\'app con qualunque mezzo e per qualsiasi scopo: a patto di mantenere sempre questi diritti a tua volta e di dare credito a Valerio Bozzolan.


Note

Quest\'applicazione è rilasciata nella speranza che sia utile a tutti ma senza NESSUNA garanzia sul suo funzionamento attuale e/o futuro.

Tutti i dati utilizzati dall\'app provengono direttamente da GTT o da simili agenzie pubbliche: se trovi che sono inesatti per qualche motivo, ti invitiamo a rivolgerti a loro.

Buon utilizzo! :)

]]>
Nome troppo corto, digita più caratteri e riprova %1$s verso %2$s %s (destinazione sconosciuta) Errore interno inaspettato, impossibile estrarre dati dal sito GTT/5T Visualizza sulla mappa Non trovo un\'applicazione dove mostrarla Posizione della fermata non trovata Fermate vicine Ricerca della posizione Nessuna fermata nei dintorni Preferenze Aggiornamento del database… Aggiornamento del database Aggiornamento database forzato Tocca per aggiornare ora il database Numero minimo di fermate Il numero di fermate da ricercare non è valido Valore errato, inserisci un numero Impostazioni Distanza massima di ricerca (m) Funzionalità sperimentali Impostazioni Generali Fermate recenti Impostazioni generali Gestione del database Comincia aggiornamento manuale del database Consenti l\'accesso alla posizione per mostrarla sulla mappa Consenti l\'accesso alla posizione per mostrare le fermate vicine Abilitare il GPS arriva alle alla fermata Mostra arrivi Mostra fermate Arrivi qui vicino Fermata rimossa dai preferiti Canale Telegram La mia posizione Segui posizione Attiva o disattiva posizione Posizione attivata Posizione disattivata La posizione è disabilitata sul dispositivo Fonte orari: %1$s App GTT Sito GTT Sito 5T Torino App Muoversi a Torino Sconosciuta Fonti orari di arrivo Scegli le fonti di orari da usare Cambiamento sorgente orari… Premi a lungo per cambiare la sorgente degli orari Nessun passaggio per le linee: Canale default delle notifiche Operazioni sul database Informazioni sul database (aggiornamento) - Servizio posizioni in tempo reale attivo + BusTO - posizioni in tempo reale + + Posizioni in tempo reale + Attività del servizio delle posizioni in tempo reale + + Servizio posizioni MaTO in tempo reale attivo Downloading trips from MaTO server Chiesto troppe volte per il permesso %1$s Non si può usare questa funzionalità senza il permesso di archivio di archivio Un bug ha fatto crashare l\'app! \nPremi \"OK\" per inviare il report agli sviluppatori via email, così potranno scovare e risolvere il tuo bug! \nIl report contiene piccole informazioni non sensibili sulla configurazione del tuo telefono e sullo stato dell\'app al momento del crash. L\'applicazione è crashata, e il crash report è stato messo negli allegati. Se vuoi, descrivi cosa stavi facendo prima che si interrompesse: \n Arrivi Mappa Preferiti Apri drawer Chiudi drawer Esperimenti Offrici un caffè Mappa Ricerca fermate Versione app Orari di arrivo Richiesto aggiornamento del database Download dati dal server MaTO Mostra direzioni in maiuscolo Non cambiare Tutto in maiuscolo Solo la prima lettera maiuscola Mostra arrivi quando tocchi una fermata Abilita esperimenti Schermata da mostrare all\'avvio Tocca per cambiare Fonte posizioni in tempo reale di bus e tram MaTO (aggiornate più spesso, può non funzionare) GTFS RT (più stabile) Linea aggiunta ai preferiti Linea rimossa dai preferiti Preferite Tocca a lungo la fermata per le opzioni Rimuovi i dati dei trip (libera spazio) Tutti i trip GTFS sono rimossi dal database Mostra introduzione open source per il trasporto pubblico di Torino. Stai usando un\'app indipendente, senza pubblicità e senza nessun tracciamento. ]]> Se ti trovi a una fermata, puoi scansionare il codice QR presente sulla palina toccando l\'icona a sinistra della barra di ricerca.]]> preferiti toccando la stella a fianco del nome.]]> fermate più vicine a te direttamente nella schermata principale...]]> posizioni in tempo reale dei bus e tram (in blu)]]> Guarda nelle Impostazioni per personalizzare l\'app come preferisci, e su Informazioni per sapere di più sull\'app e il team di sviluppo.]]> Capito, chiudi introduzione Chiudi introduzione Abilita accesso alla posizione Accesso alla posizione abilitato Accesso alla posizione non consentito dall\'utente Abilita notifiche Notifiche abilitate Backup e ripristino Importa / esporta dati Dati salvati Salva backup Importa i dati dal backup Backup importato Seleziona almeno un elemento da importare! Importa preferiti dal backup Importa preferenze dal backup
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d74a62..7ee94cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,318 +1,321 @@ 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 have 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 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, a "politically" 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!


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 Material Design icons and Volley framework.
- Android app components.
- All the contributors, and the beta testers, too!


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 Center on my location Follow me Enable or disable location Location enabled Location disabled Location is disabled on device Arrivals source: %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 - Bus live positions service is running + 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 Launching database update Downloading data from MaTO server Capitalize directions 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) 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
diff --git a/build.gradle b/build.gradle index 7e05677..1b79b23 100644 --- a/build.gradle +++ b/build.gradle @@ -1,49 +1,49 @@ // 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' } } //kotlin ext.kotlin_version = '1.9.0' - ext.coroutines_version = "1.7.3" + ext.coroutines_version = "1.8.0" dependencies { classpath 'com.android.tools.build:gradle:8.1.4' 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" preference_version = "1.2.1" work_version = "2.9.0" - acra_version = "5.7.0" - lifecycle_version = "2.4.1" + 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" } } }