Author: Adam Pioterek <adam.pioterek@protonmail.ch>
converting timetable gtfs -> sqlite
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/.idea/misc.xml b/.idea/misc.xml index 635999df1e86791ad3787e455b4524e4d8879b93..ba7052b8197ddf8ba8756022d905d03055c7ad60 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -24,7 +24,7 @@ </value> </option> </component> - <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> + <component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <output url="file://$PROJECT_DIR$/build/classes" /> </component> <component name="ProjectType"> diff --git a/app/build.gradle b/app/build.gradle index b31ded32053c4536f08e2e0259442fc88e4787f9..89b94b1a503c82e67b6c323dbb19478ae67285c2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,10 +36,12 @@ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'com.github.arimorty:floatingsearchview:2.1.1' implementation 'com.google.code.gson:gson:2.8.1' implementation 'com.squareup.okhttp3:okhttp:3.8.1' + implementation 'de.siegmar:fastcsv:1.0.2' + implementation 'com.github.ghost1372:Mzip-Android:0.4.0' } repositories { maven { url "https://maven.google.com" } - maven { url 'http://nexus.onebusaway.org/content/groups/public/' } + maven { url 'https://jitpack.io' } mavenCentral() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b3639351f1ea60324f203ffaaec81101f6200ddb..83ec6c0175dbddbcd360a33e7ac7486bbc88fb1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:theme="@style/AppTheme"> <activity android:name=".activities.DashActivity" /> <service - android:name=".TimetableDownloader" + android:name=".datasources.TimetableDownloader" android:exported="false" /> <activity @@ -39,7 +39,7 @@ android:label="@string/title_activity_help" android:theme="@style/AppTheme" /> <service - android:name=".VmClient" + android:name=".datasources.VmClient" android:enabled="true" android:exported="false" /> </application> diff --git a/app/src/main/java/ml/adamsprogs/bimba/CacheManager.kt b/app/src/main/java/ml/adamsprogs/bimba/CacheManager.kt deleted file mode 100644 index 1936daec3ff08b80d158a4e6b53b1e1a1e3a9611..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/CacheManager.kt +++ /dev/null @@ -1,136 +0,0 @@ -package ml.adamsprogs.bimba - -import android.content.Context -import android.content.SharedPreferences -import ml.adamsprogs.bimba.models.Plate -import ml.adamsprogs.bimba.gtfs.AgencyAndId - -class CacheManager private constructor(context: Context) { - companion object { - private var manager: CacheManager? = null - fun getCacheManager(context: Context): CacheManager { - return if (manager == null) { - manager = CacheManager(context) - manager!! - } else - manager!! - } - - val MAX_SIZE = 40 - } - - private var cachePreferences: SharedPreferences = context.getSharedPreferences("ml.adamsprogs.bimba.cachePreferences.cache", Context.MODE_PRIVATE) - private var cacheHitsPreferences: SharedPreferences = context.getSharedPreferences("ml.adamsprogs.bimba.cachePreferences.cacheHits", Context.MODE_PRIVATE) - - private var cache: HashMap<String, Plate> = HashMap() - private var cacheHits: HashMap<String, Int> = HashMap() - - fun keys(): List<Plate> { - return cache.map { - Plate(Plate.ID( - AgencyAndId.convertFromString(it.key.split("@")[0]), - AgencyAndId.convertFromString(it.key.split("@")[1].split(">")[0]), - it.key.split(">")[1]), - null) - } - } - - fun hasAll(plates: HashSet<Plate>): Boolean { - plates - .filterNot { has(it) } - .forEach { return false } - return true - } - - fun hasAny(plates: HashSet<Plate>): Boolean { - plates - .filter { has(it) } - .forEach { return true } - return false - } - - fun has(plate: Plate): Boolean { - return cache.containsKey(key(plate)) - } - - fun push(plates: HashSet<Plate>) { - val removeNumber = cache.size + plates.size - MAX_SIZE - val editor = cachePreferences.edit() - val editorCacheHits = cacheHitsPreferences.edit() - cacheHits.map { "${it.value}|${it.key}" }.sortedBy { it }.slice(0 until removeNumber).forEach { - val key = it.split("|")[1] - cache.remove(key) - editor.remove(key) - } - for (plate in plates) { - val key = key(plate) - cache[key] = plate - cacheHits[key] = 0 - editor.putString(key, cache[key].toString()) - editorCacheHits.putInt(key, 0) - } - editor.apply() - editorCacheHits.apply() - } - - fun push(plate: Plate) { - val editorCache = cachePreferences.edit() - val editorCacheHits = cacheHitsPreferences.edit() - if (cacheHits.size == MAX_SIZE) { - val key = cacheHits.minBy { it.value }?.key - cache.remove(key) - editorCache.remove(key) - cacheHits.remove(key) - editorCacheHits.remove(key) - } - val key = key(plate) - cache[key] = plate - cacheHits[key] = 0 - editorCache.putString(key, plate.toString()) - editorCacheHits.putInt(key, 0) - editorCache.apply() - editorCacheHits.apply() - } - - fun get(plates: HashSet<Plate>): HashSet<Plate> { - val result = HashSet<Plate>() - for (plate in plates) { - val value = get(plate) - if (value == null) - result.add(plate) - else - result.add(value) - } - return result - } - - fun get(plate: Plate): Plate? { - if (!has(plate)) - return null - val key = key(plate) - val hits = cacheHits[key] - if (hits != null) - cacheHits[key] = hits + 1 - return cache[key] - } - - fun recreate(stopDeparturesByPlates: Set<Plate>) { - stopDeparturesByPlates.forEach { cache[key(it)] = it } - } - - init { - cache = cacheFromString(cachePreferences.all) - @Suppress("UNCHECKED_CAST") - cacheHits = cacheHitsPreferences.all as HashMap<String, Int> - } - - private fun cacheFromString(preferences: Map<String, *>): HashMap<String, Plate> { - val result = HashMap<String, Plate>() - for ((key, value) in preferences.entries) { - result[key] = Plate.fromString(value as String) - } - return result - } - - private fun key(plate: Plate) = "${plate.id.line}@${plate.id.stop}>${plate.id.headsign}" -} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/MessageReceiver.kt b/app/src/main/java/ml/adamsprogs/bimba/MessageReceiver.kt index ea1e9f63efe4dea31f38f708b443eeb7e0da2870..ea1b08cc58bf6bf9ae26d92616f29057e485ded5 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/MessageReceiver.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/MessageReceiver.kt @@ -3,6 +3,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import ml.adamsprogs.bimba.datasources.TimetableDownloader +import ml.adamsprogs.bimba.datasources.VmClient import ml.adamsprogs.bimba.models.Departure import ml.adamsprogs.bimba.models.Plate diff --git a/app/src/main/java/ml/adamsprogs/bimba/TimetableDownloader.kt b/app/src/main/java/ml/adamsprogs/bimba/TimetableDownloader.kt deleted file mode 100644 index 9617f942c166b9734d043a195c187c4dea1e6ac8..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/TimetableDownloader.kt +++ /dev/null @@ -1,144 +0,0 @@ -package ml.adamsprogs.bimba - -import android.annotation.TargetApi -import android.app.IntentService -import android.app.Notification -import android.content.Context -import android.content.Intent -import android.support.v4.app.NotificationCompat -import java.net.HttpURLConnection -import java.net.URL -import java.io.* -import java.security.MessageDigest -import android.app.NotificationManager -import android.os.Build -import java.util.* - -class TimetableDownloader : IntentService("TimetableDownloader") { - companion object { - const val ACTION_DOWNLOADED = "ml.adamsprogs.bimba.timetableDownloaded" - const val EXTRA_FORCE = "force" - const val EXTRA_RESULT = "result" - const val RESULT_NO_CONNECTIVITY = "no connectivity" - const val RESULT_UP_TO_DATE = "up-to-date" - const val RESULT_DOWNLOADED = "downloaded" - } - - private lateinit var notificationManager: NotificationManager - private var size: Int = 0 - - override fun onHandleIntent(intent: Intent?) { //fixme throws something - - if (intent != null) { - notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val prefs = this.getSharedPreferences("ml.adamsprogs.bimba.prefs", Context.MODE_PRIVATE)!! - if (!NetworkStateReceiver.isNetworkAvailable(this)) { - sendResult(RESULT_NO_CONNECTIVITY) - return - } - val url = URL("http://ztm.poznan.pl/pl/dla-deweloperow/getGTFSFile") - val httpCon = url.openConnection() as HttpURLConnection - if (httpCon.responseCode != HttpURLConnection.HTTP_OK) { //IOEXCEPTION or EOFEXCEPTION or ConnectException - sendResult(RESULT_NO_CONNECTIVITY) - return - } - val lastModified = httpCon.getHeaderField("Content-Disposition").split("=")[1].trim('\"').split("_")[0] - val currentLastModified = prefs.getString("timetableLastModified", "19791012") - if (lastModified <= currentLastModified && lastModified <= today()) { - sendResult(RESULT_UP_TO_DATE) - return - } - - notify(0) - - val file = File(this.filesDir, "new_timetable.zip") - copyInputStreamToFile(httpCon.inputStream, file) - val oldFile = File(this.filesDir, "timetable.zip") - oldFile.delete() - file.renameTo(oldFile) - val prefsEditor = prefs.edit() - prefsEditor.putString("timetableLastModified", lastModified) - prefsEditor.apply() - sendResult(RESULT_DOWNLOADED) - - cancelNotification() - } - } - - private fun today(): String { - val cal = Calendar.getInstance() - val d = cal[Calendar.DAY_OF_MONTH] - val m = cal[Calendar.MONTH]+1 - val y = cal[Calendar.YEAR] - - return "%d%02d%02d".format(y, m, d) - } - - private fun sendResult(result: String) { - val broadcastIntent = Intent() - broadcastIntent.action = ACTION_DOWNLOADED - broadcastIntent.addCategory(Intent.CATEGORY_DEFAULT) - broadcastIntent.putExtra(EXTRA_RESULT, result) - sendBroadcast(broadcastIntent) - } - - private fun notify(progress: Int) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - notifyCompat(progress) - else - notifyStandard(progress) - } - - @Suppress("DEPRECATION") - private fun notifyCompat(progress: Int) { - val builder = NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle(getString(R.string.timetable_downloading)) - .setContentText("$progress KiB/$size KiB") - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .setOngoing(true) - .setProgress(size, progress, false) - notificationManager.notify(42, builder.build()) - } - - @TargetApi(Build.VERSION_CODES.O) - private fun notifyStandard(progress: Int) { - NotificationChannels.makeChannel(NotificationChannels.CHANNEL_UPDATES, "Updates", notificationManager) - val builder = Notification.Builder(this, NotificationChannels.CHANNEL_UPDATES) - .setSmallIcon(R.drawable.ic_download) - .setContentTitle(getString(R.string.timetable_downloading)) - .setContentText("$progress KiB/$size KiB") - .setCategory(Notification.CATEGORY_PROGRESS) - .setOngoing(true) - .setProgress(size, progress, false) - notificationManager.notify(42, builder.build()) - } - - private fun cancelNotification() { - notificationManager.cancel(42) - } - - private fun copyInputStreamToFile(ins: InputStream, file: File) { - val md = MessageDigest.getInstance("SHA-512") - try { - val out = FileOutputStream(file) - val buf = ByteArray(5 * 1024) - var lenSum = 0.0f - var len = 42 - while (len > 0) { - len = ins.read(buf) - if (len <= 0) - break - md.update(buf, 0, len) - out.write(buf, 0, len) - lenSum += len.toFloat() / 1024.0f - notify(lenSum.toInt()) - } - out.close() - } catch (e: Exception) { - e.printStackTrace() - } finally { - ins.close() - } - } -} diff --git a/app/src/main/java/ml/adamsprogs/bimba/VmClient.kt b/app/src/main/java/ml/adamsprogs/bimba/VmClient.kt deleted file mode 100644 index bddc008c2b2216490345092ba41be93e297bbf3d..0000000000000000000000000000000000000000 --- a/app/src/main/java/ml/adamsprogs/bimba/VmClient.kt +++ /dev/null @@ -1,240 +0,0 @@ -package ml.adamsprogs.bimba - -import android.app.Service -import android.content.Intent -import android.os.Handler -import android.os.HandlerThread -import android.os.IBinder -import android.os.Process.THREAD_PRIORITY_BACKGROUND -import android.util.Log -import com.google.gson.Gson -import ml.adamsprogs.bimba.models.* -import okhttp3.FormBody -import okhttp3.OkHttpClient -import ml.adamsprogs.bimba.gtfs.AgencyAndId -import java.io.IOException -import java.util.* -import kotlin.collections.HashMap -import kotlin.collections.HashSet -import kotlin.concurrent.thread - -class VmClient : Service() { - companion object { - const val ACTION_READY = "ml.adamsprogs.bimba.action.vm.ready" - const val EXTRA_DEPARTURES = "ml.adamsprogs.bimba.extra.vm.departures" - const val EXTRA_PLATE_ID = "ml.adamsprogs.bimba.extra.vm.plate" - } - private var handler: Handler? = null - private val tick6ZinaTim: Runnable = object : Runnable { - override fun run() { - handler!!.postDelayed(this, (12.5 * 1000).toLong()) - for (plateId in requests.keys) - downloadVM() - } - } - private val requests = HashMap<AgencyAndId, Set<Request>>() - private val vms = HashMap<AgencyAndId, HashSet<Plate>>() //HashSet<Departure>? - private val timetable = Timetable.getTimetable() - - - override fun onCreate() { - val thread = HandlerThread("ServiceStartArguments", THREAD_PRIORITY_BACKGROUND) - thread.start() - handler = Handler(thread.looper) - handler!!.postDelayed(tick6ZinaTim, (12.5 * 1000).toLong()) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val stopSegment = intent?.getParcelableExtra<StopSegment>("stop")!! - if (stopSegment.plates == null) - throw EmptyStopSegmentException() - val action = intent.action - val once = intent.getBooleanExtra("once", false) - if (action == "request") { - if (isAlreadyRequested(stopSegment)) { - incrementRequest(stopSegment) - sendResult(stopSegment) - } else { - if (!once) - addRequest(stopSegment) - thread { - downloadVM(stopSegment) - } - } - } else if (action == "remove") { - decrementRequest(stopSegment) - cleanRequests() - } - return START_STICKY - } - - private fun cleanRequests() { - val newMap = HashMap<AgencyAndId, Set<Request>>() - requests.forEach { - newMap[it.key] = it.value.minus(it.value.filter { it.times == 0 }) - } - newMap.forEach { requests[it.key] = it.value } - } - - private fun addRequest(stopSegment: StopSegment) { - if (requests[stopSegment.stop] == null) { - requests[stopSegment.stop] = stopSegment.plates!! - .map { Request(it, 1) } - .toSet() - } else { - var req = requests[stopSegment.stop]!! - stopSegment.plates!!.forEach { - val plate = it - if (req.any { it.plate == plate }) { - req.filter { it.plate == plate }[0].times++ - } else { - req = req.plus(Request(it, 1)) - } - requests[stopSegment.stop] = req - } - } - } - - private fun sendResult(stop: StopSegment) { - vms[stop.stop]?.filter { - val plate = it - stop.plates!!.any { it == plate.id } - }?.forEach { sendResult(it.id, it.departures?.get(today())) } - } - - private fun today(): AgencyAndId { - return timetable.getServiceForToday() - } - - private fun incrementRequest(stopSegment: StopSegment) { - stopSegment.plates!!.forEach { - val plateId = it - requests[it.stop]!!.filter { it.plate == plateId }.forEach { it.times++ } - } - } - - private fun decrementRequest(stopSegment: StopSegment) { - stopSegment.plates!!.forEach { - val plateId = it - requests[it.stop]!!.filter { it.plate == plateId }.forEach { it.times-- } - } - } - - private fun isAlreadyRequested(stopSegment: StopSegment): Boolean { - val platesIn = requests[stopSegment.stop]?.map { it.plate }?.toSet() - val platesOut = stopSegment.plates - return (platesOut == platesIn || platesIn!!.containsAll(platesOut!!)) - } - - - override fun onBind(intent: Intent): IBinder? { - return null - } - - override fun onDestroy() { - } - - private fun downloadVM() { - vms.forEach { - downloadVM(StopSegment(it.key, it.value.map { it.id }.toSet())) - } - } - - private fun downloadVM(stopSegment: StopSegment) { - if (!NetworkStateReceiver.isNetworkAvailable(this)) { - stopSegment.plates!!.forEach { - sendResult(it, null) - } - return - } - - val stopSymbol = timetable.getStopCode(stopSegment.stop) - val client = OkHttpClient() - val url = "http://www.peka.poznan.pl/vm/method.vm?ts=${Calendar.getInstance().timeInMillis}" - val formBody = FormBody.Builder() - .add("method", "getTimes") - .add("p0", "{\"symbol\": \"$stopSymbol\"}") - .build() - val request = okhttp3.Request.Builder() - .url(url) - .post(formBody) - .build() - - Log.i("VM", "created http request") - - val responseBody: String? - try { - responseBody = client.newCall(request).execute().body()?.string() - } catch (e: IOException) { - stopSegment.plates!!.forEach { - sendResult(it, null) - } - return - } - - Log.i("VM", "received http response") - - if (responseBody?.get(0) == '<') { - stopSegment.plates!!.forEach { - sendResult(it, null) - } - return - } - - val javaRootMapObject = Gson().fromJson(responseBody, HashMap::class.java) - val times = (javaRootMapObject["success"] as Map<*, *>)["times"] as List<*> - stopSegment.plates!!.forEach { downloadVM(it, times) } - - } - - private fun downloadVM(plateId: Plate.ID, times: List<*>) { - val date = Calendar.getInstance() - val todayDay = "${date.get(Calendar.DATE)}".padStart(2, '0') - val todayMode = timetable.calendarToMode(AgencyAndId(timetable.getServiceForToday().id)) - - val departures = HashSet<Departure>() - - times.forEach { - val thisLine = timetable.getLineForNumber((it as Map<*, *>)["line"] as String) - val thisHeadsign = it["direction"] as String - val thisPlateId = Plate.ID(thisLine, plateId.stop, thisHeadsign) - if (plateId == thisPlateId) { - val departureDay = (it["departure"] as String).split("T")[0].split("-")[2] - val departureTime = calendarFromIso(it["departure"] as String).secondsAfterMidnight() - val departure = Departure(plateId.line, todayMode, departureTime, false, - ArrayList(), it["direction"] as String, it["realTime"] as Boolean, - departureDay != todayDay, it["onStopPoint"] as Boolean) - departures.add(departure) - } - - } - - - val departuresForPlate = HashMap<AgencyAndId, HashSet<Departure>>() - departuresForPlate[timetable.getServiceForToday()] = departures - val vm = vms[plateId.stop]!! - vm.remove(vm.filter { it.id == plateId }[0]) - vm.add(Plate(plateId, departuresForPlate)) - vms[plateId.stop] = vm - if (departures.isEmpty()) - sendResult(plateId, null) - else - sendResult(plateId, departures) - } - - private fun sendResult(plateId: Plate.ID, departures: HashSet<Departure>?) { - val broadcastIntent = Intent() - broadcastIntent.action = ACTION_READY - broadcastIntent.addCategory(Intent.CATEGORY_DEFAULT) - broadcastIntent.putStringArrayListExtra(EXTRA_DEPARTURES, departures?.map { it.toString() } as java.util.ArrayList<String>) - broadcastIntent.putExtra(EXTRA_PLATE_ID, plateId) - sendBroadcast(broadcastIntent) - } - - data class Request(val plate: Plate.ID, var times: Int) - - class EmptyStopSegmentException : Exception() -} - -//note application stops the service on exit - diff --git a/app/src/main/java/ml/adamsprogs/bimba/activities/DashActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/activities/DashActivity.kt index 4387b91e2389753d388311ef9d68ab7a4776c794..6c743b8b0cc19a297ac05a122c8c6343ec37d00c 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/activities/DashActivity.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/activities/DashActivity.kt @@ -21,6 +21,8 @@ import java.util.* import android.os.Bundle import android.util.Log import kotlinx.android.synthetic.main.activity_dash.* +import ml.adamsprogs.bimba.datasources.TimetableDownloader +import ml.adamsprogs.bimba.datasources.VmClient //todo cards class DashActivity : AppCompatActivity(), MessageReceiver.OnTimetableDownloadListener, @@ -223,15 +225,14 @@ } override fun onTimetableDownload(result: String?) { val message: String = when (result) { - TimetableDownloader.RESULT_DOWNLOADED -> getString(R.string.timetable_downloaded) + TimetableDownloader.RESULT_DOWNLOADED -> getString(R.string.refreshing_cache) TimetableDownloader.RESULT_NO_CONNECTIVITY -> getString(R.string.no_connectivity) TimetableDownloader.RESULT_UP_TO_DATE -> getString(R.string.timetable_up_to_date) + TimetableDownloader.RESULT_FINISHED -> getString(R.string.timetable_downloaded) else -> getString(R.string.error_try_later) } Snackbar.make(findViewById(R.id.drawer_layout), message, Snackbar.LENGTH_LONG).show() - if (result == TimetableDownloader.RESULT_DOWNLOADED) { - Snackbar.make(findViewById(R.id.drawer_layout), getString(R.string.refreshing_cache), Snackbar.LENGTH_LONG).show() - timetable.refresh(context) + if (result == TimetableDownloader.RESULT_FINISHED) { stops = timetable.getStops() as ArrayList<StopSuggestion> drawerView.menu.findItem(R.id.drawer_validity_since).title = getString(R.string.valid_since, timetable.getValidSince()) diff --git a/app/src/main/java/ml/adamsprogs/bimba/activities/NoDbActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/activities/NoDbActivity.kt index b4c1764983d6054eac57c058afa3ec3999338bd6..c08d5d5737d3ea5da8ffdae795c4fa010f62737f 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/activities/NoDbActivity.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/activities/NoDbActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.content.IntentFilter import ml.adamsprogs.bimba.* import kotlinx.android.synthetic.main.activity_nodb.* +import ml.adamsprogs.bimba.datasources.TimetableDownloader class NoDbActivity : AppCompatActivity(), NetworkStateReceiver.OnConnectivityChangeListener, MessageReceiver.OnTimetableDownloadListener { private val networkStateReceiver = NetworkStateReceiver() @@ -63,12 +64,14 @@ } override fun onTimetableDownload(result: String?) { when (result) { - TimetableDownloader.RESULT_DOWNLOADED -> { + TimetableDownloader.RESULT_FINISHED -> { timetableDownloadReceiver.removeOnTimetableDownloadListener(this) networkStateReceiver.removeOnConnectivityChangeListener(this) startActivity(Intent(this, DashActivity::class.java)) finish() } + TimetableDownloader.RESULT_DOWNLOADED -> + no_db_caption.text = getString(R.string.timetable_converting) else -> no_db_caption.text = getString(R.string.error_try_later) } } diff --git a/app/src/main/java/ml/adamsprogs/bimba/activities/StopActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/activities/StopActivity.kt index 4dcb586f4923dfa75c56a0946667561d1ce07fa5..27baedc722898d0b2ae894c661346fb75c100ee8 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/activities/StopActivity.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/activities/StopActivity.kt @@ -17,6 +17,8 @@ import ml.adamsprogs.bimba.models.* import ml.adamsprogs.bimba.* import kotlin.concurrent.thread import kotlinx.android.synthetic.main.activity_stop.* +import ml.adamsprogs.bimba.datasources.TimetableDownloader +import ml.adamsprogs.bimba.datasources.VmClient import ml.adamsprogs.bimba.gtfs.AgencyAndId class StopActivity : AppCompatActivity(), MessageReceiver.OnTimetableDownloadListener, MessageReceiver.OnVmListener, Favourite.OnVmPreparedListener { @@ -165,7 +167,15 @@ } } override fun onTimetableDownload(result: String?) { - TODO("not implemented") //To change body of created functions use File | Settings | File Templates. + val message: String = when (result) { + TimetableDownloader.RESULT_DOWNLOADED -> getString(R.string.refreshing_cache) + TimetableDownloader.RESULT_NO_CONNECTIVITY -> getString(R.string.no_connectivity) + TimetableDownloader.RESULT_UP_TO_DATE -> getString(R.string.timetable_up_to_date) + TimetableDownloader.RESULT_FINISHED -> getString(R.string.timetable_downloaded) + else -> getString(R.string.error_try_later) + } + Snackbar.make(findViewById(R.id.drawer_layout), message, Snackbar.LENGTH_LONG).show() + //todo refresh } private fun selectTodayPage() { //todo Services diff --git a/app/src/main/java/ml/adamsprogs/bimba/datasources/CacheManager.kt b/app/src/main/java/ml/adamsprogs/bimba/datasources/CacheManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..f1bfa2a009a5b0ca6704a4ed1b39178c1748cbfd --- /dev/null +++ b/app/src/main/java/ml/adamsprogs/bimba/datasources/CacheManager.kt @@ -0,0 +1,136 @@ +package ml.adamsprogs.bimba.datasources + +import android.content.Context +import android.content.SharedPreferences +import ml.adamsprogs.bimba.models.Plate +import ml.adamsprogs.bimba.gtfs.AgencyAndId + +class CacheManager private constructor(context: Context) { + companion object { + private var manager: CacheManager? = null + fun getCacheManager(context: Context): CacheManager { + return if (manager == null) { + manager = CacheManager(context) + manager!! + } else + manager!! + } + + val MAX_SIZE = 40 + } + + private var cachePreferences: SharedPreferences = context.getSharedPreferences("ml.adamsprogs.bimba.cachePreferences.cache", Context.MODE_PRIVATE) + private var cacheHitsPreferences: SharedPreferences = context.getSharedPreferences("ml.adamsprogs.bimba.cachePreferences.cacheHits", Context.MODE_PRIVATE) + + private var cache: HashMap<String, Plate> = HashMap() + private var cacheHits: HashMap<String, Int> = HashMap() + + fun keys(): List<Plate> { + return cache.map { + Plate(Plate.ID( + AgencyAndId.convertFromString(it.key.split("@")[0]), + AgencyAndId.convertFromString(it.key.split("@")[1].split(">")[0]), + it.key.split(">")[1]), + null) + } + } + + fun hasAll(plates: HashSet<Plate>): Boolean { + plates + .filterNot { has(it) } + .forEach { return false } + return true + } + + fun hasAny(plates: HashSet<Plate>): Boolean { + plates + .filter { has(it) } + .forEach { return true } + return false + } + + fun has(plate: Plate): Boolean { + return cache.containsKey(key(plate)) + } + + fun push(plates: HashSet<Plate>) { + val removeNumber = cache.size + plates.size - MAX_SIZE + val editor = cachePreferences.edit() + val editorCacheHits = cacheHitsPreferences.edit() + cacheHits.map { "${it.value}|${it.key}" }.sortedBy { it }.slice(0 until removeNumber).forEach { + val key = it.split("|")[1] + cache.remove(key) + editor.remove(key) + } + for (plate in plates) { + val key = key(plate) + cache[key] = plate + cacheHits[key] = 0 + editor.putString(key, cache[key].toString()) + editorCacheHits.putInt(key, 0) + } + editor.apply() + editorCacheHits.apply() + } + + fun push(plate: Plate) { + val editorCache = cachePreferences.edit() + val editorCacheHits = cacheHitsPreferences.edit() + if (cacheHits.size == MAX_SIZE) { + val key = cacheHits.minBy { it.value }?.key + cache.remove(key) + editorCache.remove(key) + cacheHits.remove(key) + editorCacheHits.remove(key) + } + val key = key(plate) + cache[key] = plate + cacheHits[key] = 0 + editorCache.putString(key, plate.toString()) + editorCacheHits.putInt(key, 0) + editorCache.apply() + editorCacheHits.apply() + } + + fun get(plates: HashSet<Plate>): HashSet<Plate> { + val result = HashSet<Plate>() + for (plate in plates) { + val value = get(plate) + if (value == null) + result.add(plate) + else + result.add(value) + } + return result + } + + fun get(plate: Plate): Plate? { + if (!has(plate)) + return null + val key = key(plate) + val hits = cacheHits[key] + if (hits != null) + cacheHits[key] = hits + 1 + return cache[key] + } + + fun recreate(stopDeparturesByPlates: Set<Plate>) { + stopDeparturesByPlates.forEach { cache[key(it)] = it } + } + + init { + cache = cacheFromString(cachePreferences.all) + @Suppress("UNCHECKED_CAST") + cacheHits = cacheHitsPreferences.all as HashMap<String, Int> + } + + private fun cacheFromString(preferences: Map<String, *>): HashMap<String, Plate> { + val result = HashMap<String, Plate>() + for ((key, value) in preferences.entries) { + result[key] = Plate.fromString(value as String) + } + return result + } + + private fun key(plate: Plate) = "${plate.id.line}@${plate.id.stop}>${plate.id.headsign}" +} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableConverter.kt b/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableConverter.kt new file mode 100644 index 0000000000000000000000000000000000000000..c5062388b6ba89e806378e681282d2ea5e554e6f --- /dev/null +++ b/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableConverter.kt @@ -0,0 +1,299 @@ +package ml.adamsprogs.bimba.datasources + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import java.io.File +import de.siegmar.fastcsv.reader.CsvRow +import de.siegmar.fastcsv.reader.CsvReader +import ir.mahdi.mzip.zip.ZipArchive +import java.nio.charset.StandardCharsets + + +class TimetableConverter(from: File, to: File, context: Context) { + private val db: SQLiteDatabase = SQLiteDatabase.openOrCreateDatabase(to, null) + + init { + val target = File(context.filesDir, "gtfs_files") + target.mkdir() + ZipArchive.unzip(from.path, target.path, "") + createTables() + insertAgency(context) + insertCalendar(context) + insertCalendarDates(context) + insertFeedInfo(context) + insertRoutes(context) + insertShapes(context) + insertStops(context) + insertStopTimes(context) + insertTrips(context) + target.deleteRecursively() + } + + private fun createTables() { + db.rawQuery("create table agency(" + + "agency_id TEXT PRIMARY KEY," + + "agency_name TEXT," + + "agency_url TEXT," + + "agency_timezone TEXT," + + "agency_phone TEXT," + + "agency_lang TEXT" + + ")", null) + db.rawQuery("create table calendar(" + + "service_id TEXT PRIMARY KEY," + + "monday INT," + + "tuesday INT," + + "wednesday INT," + + "thursday INT," + + "friday INT," + + "saturday INT," + + "sunday INT," + + "start_date TEXT," + + "end_date TEXT" + + ")", null) + db.rawQuery("create table calendar_dates(" + + "service_id TEXT," + + "date TEXT," + + "exception_type INT," + + "FOREIGN KEY(service_id) REFERENCES calendar(service_id)" + + ")", null) + db.rawQuery("create table feed_info(" + + "feed_publisher_name TEXT PRIMARY KEY," + + "feed_publisher_url TEXT," + + "feed_lang TEXT," + + "feed_start_date TEXT," + + "feed_end_date TEXT" + + ")", null) + db.rawQuery("create table routes(" + + "route_id TEXT PRIMARY KEY," + + "agency_id TEXT," + + "route_short_name TEXT," + + "route_long_name TEXT," + + "route_desc TEXT," + + "route_type INT," + + "route_color TEXT," + + "route_text_color TEXT," + + "FOREIGN KEY(agency_id) REFERENCES agency(agency_id)" + + ")", null) + db.rawQuery("create table shapes(" + + "shape_id TEXT PRIMARY KEY," + + "shape_pt_lat DOUBLE," + + "shape_pt_lon DOUBLE," + + "shape_pt_sequence INT" + + ")", null) + db.rawQuery("create table stops" + + "stop_id TEXT PRIMARY KEY," + + "stop_code TEXT," + + "stop_name TEXT," + + "stop_lat DOUBLE," + + "stop_lon DOUBLE," + + "zone_id TEXT" + + ")", null) + db.rawQuery("create table stop_times(" + + "trip_id TEXT PRIMARY KEY," + + "arrival_time TEXT," + + "departure_time TEXT," + + "stop_id TEXT," + + "stop_sequence INT," + + "stop_headsign TEXT," + + "pickup_type INT," + + "drop_off_type INT," + + "FOREIGN KEY(stop_id) REFERENCES stops(stop_id)" + + ")", null) + db.rawQuery("create table trips(" + + "route_id TEXT," + + "service_id TEXT," + + "trip_id TEXT PRIMARY KEY," + + "trip_headsign TEXT," + + "direction_id INT," + + "shape_id TEXT," + + "wheelchair_accessible INT," + + "FOREIGN KEY(route_id) REFERENCES routes(route_id)," + + "FOREIGN KEY(service_id) REFERENCES calendar(service_id)," + + "FOREIGN KEY(shape_id) REFERENCE shapes(shape_id)" + + ")", null) + } + + private fun insertAgency(context: Context) { + val file = File(context.filesDir, "gtfs_files/agency.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val id = row!!.getField("agency_id") + val name = row!!.getField("agency_name") + val url = row!!.getField("agency_url") + val timezone = row!!.getField("agency_timezone") + val phone = row!!.getField("agency_phone") + val lang = row!!.getField("agency_lang") + db.rawQuery("insert into agency values(?, ?, ?, ?, ?, ?)", + arrayOf(id, name, url, timezone, phone, lang)) + } + } + } + + private fun insertCalendar(context: Context) { + val file = File(context.filesDir, "gtfs_files/calendar.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val serviceId = row!!.getField("service_id") + val monday = row!!.getField("monday") + val tuesday = row!!.getField("tuesday") + val wednesday = row!!.getField("wednesday") + val thursday = row!!.getField("thursday") + val friday = row!!.getField("friday") + val saturday = row!!.getField("saturday") + val sunday = row!!.getField("sunday") + val startDate = row!!.getField("start_date") + val endDate = row!!.getField("end_date") + db.rawQuery("insert into calendar values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(serviceId, monday, tuesday, wednesday, thursday, friday, saturday, + sunday, startDate, endDate)) + } + } + } + + private fun insertCalendarDates(context: Context) { + val file = File(context.filesDir, "gtfs_files/calendar_dates.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val serviceId = row!!.getField("service_id") + val date = row!!.getField("date") + val exceptionType = row!!.getField("exceptionType") + db.rawQuery("insert into calendar_dates values(?, ?, ?)", + arrayOf(serviceId, date, exceptionType)) + } + } + } + + private fun insertFeedInfo(context: Context) { + val file = File(context.filesDir, "gtfs_files/feed_info.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val name = row!!.getField("feed_publisher_name") + val url = row!!.getField("feed_publisher_url") + val lang = row!!.getField("feed_lang") + val startDate = row!!.getField("feed_start_date") + val endDate = row!!.getField("feed_end_date") + db.rawQuery("insert into feed_info values(?, ?, ?, ?, ?)", + arrayOf(name, url, lang, startDate, endDate)) + } + } + } + + private fun insertRoutes(context: Context) { + val file = File(context.filesDir, "gtfs_files/routes.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val id = row!!.getField("route_id") + val agencyId = row!!.getField("agency_id") + val shortName = row!!.getField("route_short_name") + val longName = row!!.getField("route_long_name") + val description = row!!.getField("route_desc") + val type = row!!.getField("route_type") + val colour = row!!.getField("route_color") + val textColour = row!!.getField("route_text_color") + db.rawQuery("insert into routes values(?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(id, agencyId, shortName, longName, description, type, colour, + textColour)) + } + } + } + + private fun insertShapes(context: Context) { + val file = File(context.filesDir, "gtfs_files/shapes.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val id = row!!.getField("shape_id") + val latitude = row!!.getField("shape_pt_lat") + val longitude = row!!.getField("shape_pt_lon") + val sequence = row!!.getField("shape_pt_sequence") + db.rawQuery("insert into shapes values(?, ?, ?, ?, ?)", + arrayOf(id, latitude, longitude, sequence)) + } + } + } + + private fun insertStops(context: Context) { + val file = File(context.filesDir, "gtfs_files/stops.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val id = row!!.getField("stop_id") + val code = row!!.getField("stop_code") + val name = row!!.getField("stop_name") + val latitude = row!!.getField("stop_lat") + val longitude = row!!.getField("stop_lon") + val zone = row!!.getField("zone_id") + db.rawQuery("insert into stops values(?, ?, ?, ?, ?, ?)", + arrayOf(id, code, name, latitude, longitude, zone)) + } + } + } + + private fun insertStopTimes(context: Context) { + val file = File(context.filesDir, "gtfs_files/stop_times.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val id = row!!.getField("trip_id") + val arrival = row!!.getField("arrival_time") + val departure = row!!.getField("departure_time") + val stop = row!!.getField("stop_id") + val sequence = row!!.getField("stop_sequence") + val headsign = row!!.getField("stop_headsign") + val pickup = row!!.getField("pickup_type") + val dropOff = row!!.getField("drop_off_type") + db.rawQuery("insert into stop_times values(?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(id, arrival, departure, stop, sequence, headsign, pickup, dropOff)) + } + } + } + + private fun insertTrips(context: Context) { + val file = File(context.filesDir, "gtfs_files/trips.txt") + val csvReader = CsvReader() + csvReader.setContainsHeader(true) + + csvReader.parse(file, StandardCharsets.UTF_8).use { + var row: CsvRow? = null + while ({ row = it.nextRow(); row }() != null) { + val route = row!!.getField("route_id") + val service = row!!.getField("service_id") + val id = row!!.getField("trip_id") + val headsign = row!!.getField("headsign") + val direction = row!!.getField("direction") + val shape = row!!.getField("shape") + db.rawQuery("insert into trpis values(?, ?, ?, ?, ?, ?)", + arrayOf(route, service, id, headsign, direction, shape)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableDownloader.kt b/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableDownloader.kt new file mode 100644 index 0000000000000000000000000000000000000000..e8b3232b47b94589d1a1a18fb68666998e7c695c --- /dev/null +++ b/app/src/main/java/ml/adamsprogs/bimba/datasources/TimetableDownloader.kt @@ -0,0 +1,190 @@ +package ml.adamsprogs.bimba.datasources + +import android.annotation.TargetApi +import android.app.IntentService +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.support.v4.app.NotificationCompat +import java.net.HttpURLConnection +import java.net.URL +import java.io.* +import java.security.MessageDigest +import android.app.NotificationManager +import android.os.Build +import ml.adamsprogs.bimba.NetworkStateReceiver +import ml.adamsprogs.bimba.NotificationChannels +import ml.adamsprogs.bimba.R +import ml.adamsprogs.bimba.models.Timetable +import java.util.* + +class TimetableDownloader : IntentService("TimetableDownloader") { + companion object { + const val ACTION_DOWNLOADED = "ml.adamsprogs.bimba.timetableDownloaded" + const val EXTRA_FORCE = "force" + const val EXTRA_RESULT = "result" + const val RESULT_NO_CONNECTIVITY = "no connectivity" + const val RESULT_UP_TO_DATE = "up-to-date" + const val RESULT_DOWNLOADED = "downloaded" + const val RESULT_FINISHED = "finished" + } + + private lateinit var notificationManager: NotificationManager + private var size: Int = 0 + + override fun onHandleIntent(intent: Intent?) { //fixme throws something + + if (intent != null) { + notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val prefs = this.getSharedPreferences("ml.adamsprogs.bimba.prefs", Context.MODE_PRIVATE)!! + if (!NetworkStateReceiver.isNetworkAvailable(this)) { + sendResult(RESULT_NO_CONNECTIVITY) + return + } + val url = URL("http://ztm.poznan.pl/pl/dla-deweloperow/getGTFSFile") + val httpCon = url.openConnection() as HttpURLConnection + if (httpCon.responseCode != HttpURLConnection.HTTP_OK) { //IOEXCEPTION or EOFEXCEPTION or ConnectException + sendResult(RESULT_NO_CONNECTIVITY) + return + } + val lastModified = httpCon.getHeaderField("Content-Disposition").split("=")[1].trim('\"').split("_")[0] + //todo size + val currentLastModified = prefs.getString("timetableLastModified", "19791012") + if (lastModified <= currentLastModified && lastModified <= today()) { + sendResult(RESULT_UP_TO_DATE) + return + } + + notifyDownloading(0) + + val gtfs = File(this.filesDir, "timetable.zip") + val db = File(this.filesDir, "timetable.db") + copyInputStreamToFile(httpCon.inputStream, gtfs) + val prefsEditor = prefs.edit() + prefsEditor.putString("timetableLastModified", lastModified) + prefsEditor.apply() + sendResult(RESULT_DOWNLOADED) + + notifyConverting() + + db.delete() + TimetableConverter(gtfs, File(this.filesDir, "timetable.db"), this) + gtfs.delete() + Timetable.getTimetable().refresh(this) + + cancelNotification() + + sendResult(RESULT_FINISHED) + } + } + + private fun today(): String { + val cal = Calendar.getInstance() + val d = cal[Calendar.DAY_OF_MONTH] + val m = cal[Calendar.MONTH]+1 + val y = cal[Calendar.YEAR] + + return "%d%02d%02d".format(y, m, d) + } + + private fun sendResult(result: String) { + val broadcastIntent = Intent() + broadcastIntent.action = ACTION_DOWNLOADED + broadcastIntent.addCategory(Intent.CATEGORY_DEFAULT) + broadcastIntent.putExtra(EXTRA_RESULT, result) + sendBroadcast(broadcastIntent) + } + + private fun notifyDownloading(progress: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + notifyCompat(progress) + else + notifyStandard(progress) + } + + @Suppress("DEPRECATION") + private fun notifyCompat(progress: Int) { + val builder = NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.timetable_downloading)) + .setContentText("$progress KiB/$size KiB") + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOngoing(true) + .setProgress(size, progress, false) + notificationManager.notify(42, builder.build()) + } + + @TargetApi(Build.VERSION_CODES.O) + private fun notifyStandard(progress: Int) { + NotificationChannels.makeChannel(NotificationChannels.CHANNEL_UPDATES, "Updates", notificationManager) + val builder = Notification.Builder(this, NotificationChannels.CHANNEL_UPDATES) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.timetable_downloading)) + .setContentText("$progress KiB/$size KiB") + .setCategory(Notification.CATEGORY_PROGRESS) + .setOngoing(true) + .setProgress(size, progress, false) + notificationManager.notify(42, builder.build()) + } + + private fun notifyConverting() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) + notifyCompatConverting() + else + notifyStandardConverting() + + } + + @Suppress("DEPRECATION") + private fun notifyCompatConverting() { + val builder = NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.timetable_converting)) + .setContentText("") + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOngoing(true) + .setProgress(0, 0, true) + notificationManager.notify(42, builder.build()) + } + + @TargetApi(Build.VERSION_CODES.O) + private fun notifyStandardConverting() { + NotificationChannels.makeChannel(NotificationChannels.CHANNEL_UPDATES, "Updates", notificationManager) + val builder = Notification.Builder(this, NotificationChannels.CHANNEL_UPDATES) + .setSmallIcon(R.drawable.ic_download) + .setContentTitle(getString(R.string.timetable_converting)) + .setContentText("") + .setCategory(Notification.CATEGORY_PROGRESS) + .setOngoing(true) + .setProgress(0, 0, true) + notificationManager.notify(42, builder.build()) + } + + private fun cancelNotification() { + notificationManager.cancel(42) + } + + private fun copyInputStreamToFile(ins: InputStream, file: File) { + val md = MessageDigest.getInstance("SHA-512") + try { + val out = FileOutputStream(file) + val buf = ByteArray(5 * 1024) + var lenSum = 0.0f + var len = 42 + while (len > 0) { + len = ins.read(buf) + if (len <= 0) + break + md.update(buf, 0, len) + out.write(buf, 0, len) + lenSum += len.toFloat() / 1024.0f + notifyDownloading(lenSum.toInt()) + } + out.close() + } catch (e: Exception) { + e.printStackTrace() + } finally { + ins.close() + } + } +} diff --git a/app/src/main/java/ml/adamsprogs/bimba/datasources/VmClient.kt b/app/src/main/java/ml/adamsprogs/bimba/datasources/VmClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..e5b0a6357e3d5cb9d5065d89bcf81cd94ef5ebe7 --- /dev/null +++ b/app/src/main/java/ml/adamsprogs/bimba/datasources/VmClient.kt @@ -0,0 +1,243 @@ +package ml.adamsprogs.bimba.datasources + +import android.app.Service +import android.content.Intent +import android.os.Handler +import android.os.HandlerThread +import android.os.IBinder +import android.os.Process.THREAD_PRIORITY_BACKGROUND +import android.util.Log +import com.google.gson.Gson +import ml.adamsprogs.bimba.NetworkStateReceiver +import ml.adamsprogs.bimba.calendarFromIso +import ml.adamsprogs.bimba.models.* +import okhttp3.FormBody +import okhttp3.OkHttpClient +import ml.adamsprogs.bimba.gtfs.AgencyAndId +import ml.adamsprogs.bimba.secondsAfterMidnight +import java.io.IOException +import java.util.* +import kotlin.collections.HashMap +import kotlin.collections.HashSet +import kotlin.concurrent.thread + +class VmClient : Service() { + companion object { + const val ACTION_READY = "ml.adamsprogs.bimba.action.vm.ready" + const val EXTRA_DEPARTURES = "ml.adamsprogs.bimba.extra.vm.departures" + const val EXTRA_PLATE_ID = "ml.adamsprogs.bimba.extra.vm.plate" + } + private var handler: Handler? = null + private val tick6ZinaTim: Runnable = object : Runnable { + override fun run() { + handler!!.postDelayed(this, (12.5 * 1000).toLong()) + for (plateId in requests.keys) + downloadVM() + } + } + private val requests = HashMap<AgencyAndId, Set<Request>>() + private val vms = HashMap<AgencyAndId, HashSet<Plate>>() //HashSet<Departure>? + private val timetable = Timetable.getTimetable() + + + override fun onCreate() { + val thread = HandlerThread("ServiceStartArguments", THREAD_PRIORITY_BACKGROUND) + thread.start() + handler = Handler(thread.looper) + handler!!.postDelayed(tick6ZinaTim, (12.5 * 1000).toLong()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val stopSegment = intent?.getParcelableExtra<StopSegment>("stop")!! + if (stopSegment.plates == null) + throw EmptyStopSegmentException() + val action = intent.action + val once = intent.getBooleanExtra("once", false) + if (action == "request") { + if (isAlreadyRequested(stopSegment)) { + incrementRequest(stopSegment) + sendResult(stopSegment) + } else { + if (!once) + addRequest(stopSegment) + thread { + downloadVM(stopSegment) + } + } + } else if (action == "remove") { + decrementRequest(stopSegment) + cleanRequests() + } + return START_STICKY + } + + private fun cleanRequests() { + val newMap = HashMap<AgencyAndId, Set<Request>>() + requests.forEach { + newMap[it.key] = it.value.minus(it.value.filter { it.times == 0 }) + } + newMap.forEach { requests[it.key] = it.value } + } + + private fun addRequest(stopSegment: StopSegment) { + if (requests[stopSegment.stop] == null) { + requests[stopSegment.stop] = stopSegment.plates!! + .map { Request(it, 1) } + .toSet() + } else { + var req = requests[stopSegment.stop]!! + stopSegment.plates!!.forEach { + val plate = it + if (req.any { it.plate == plate }) { + req.filter { it.plate == plate }[0].times++ + } else { + req = req.plus(Request(it, 1)) + } + requests[stopSegment.stop] = req + } + } + } + + private fun sendResult(stop: StopSegment) { + vms[stop.stop]?.filter { + val plate = it + stop.plates!!.any { it == plate.id } + }?.forEach { sendResult(it.id, it.departures?.get(today())) } + } + + private fun today(): AgencyAndId { + return timetable.getServiceForToday() + } + + private fun incrementRequest(stopSegment: StopSegment) { + stopSegment.plates!!.forEach { + val plateId = it + requests[it.stop]!!.filter { it.plate == plateId }.forEach { it.times++ } + } + } + + private fun decrementRequest(stopSegment: StopSegment) { + stopSegment.plates!!.forEach { + val plateId = it + requests[it.stop]!!.filter { it.plate == plateId }.forEach { it.times-- } + } + } + + private fun isAlreadyRequested(stopSegment: StopSegment): Boolean { + val platesIn = requests[stopSegment.stop]?.map { it.plate }?.toSet() + val platesOut = stopSegment.plates + return (platesOut == platesIn || platesIn!!.containsAll(platesOut!!)) + } + + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onDestroy() { + } + + private fun downloadVM() { + vms.forEach { + downloadVM(StopSegment(it.key, it.value.map { it.id }.toSet())) + } + } + + private fun downloadVM(stopSegment: StopSegment) { + if (!NetworkStateReceiver.isNetworkAvailable(this)) { + stopSegment.plates!!.forEach { + sendResult(it, null) + } + return + } + + val stopSymbol = timetable.getStopCode(stopSegment.stop) + val client = OkHttpClient() + val url = "http://www.peka.poznan.pl/vm/method.vm?ts=${Calendar.getInstance().timeInMillis}" + val formBody = FormBody.Builder() + .add("method", "getTimes") + .add("p0", "{\"symbol\": \"$stopSymbol\"}") + .build() + val request = okhttp3.Request.Builder() + .url(url) + .post(formBody) + .build() + + Log.i("VM", "created http request") + + val responseBody: String? + try { + responseBody = client.newCall(request).execute().body()?.string() + } catch (e: IOException) { + stopSegment.plates!!.forEach { + sendResult(it, null) + } + return + } + + Log.i("VM", "received http response") + + if (responseBody?.get(0) == '<') { + stopSegment.plates!!.forEach { + sendResult(it, null) + } + return + } + + val javaRootMapObject = Gson().fromJson(responseBody, HashMap::class.java) + val times = (javaRootMapObject["success"] as Map<*, *>)["times"] as List<*> + stopSegment.plates!!.forEach { downloadVM(it, times) } + + } + + private fun downloadVM(plateId: Plate.ID, times: List<*>) { + val date = Calendar.getInstance() + val todayDay = "${date.get(Calendar.DATE)}".padStart(2, '0') + val todayMode = timetable.calendarToMode(AgencyAndId(timetable.getServiceForToday().id)) + + val departures = HashSet<Departure>() + + times.forEach { + val thisLine = timetable.getLineForNumber((it as Map<*, *>)["line"] as String) + val thisHeadsign = it["direction"] as String + val thisPlateId = Plate.ID(thisLine, plateId.stop, thisHeadsign) + if (plateId == thisPlateId) { + val departureDay = (it["departure"] as String).split("T")[0].split("-")[2] + val departureTime = calendarFromIso(it["departure"] as String).secondsAfterMidnight() + val departure = Departure(plateId.line, todayMode, departureTime, false, + ArrayList(), it["direction"] as String, it["realTime"] as Boolean, + departureDay != todayDay, it["onStopPoint"] as Boolean) + departures.add(departure) + } + + } + + + val departuresForPlate = HashMap<AgencyAndId, HashSet<Departure>>() + departuresForPlate[timetable.getServiceForToday()] = departures + val vm = vms[plateId.stop]!! + vm.remove(vm.filter { it.id == plateId }[0]) + vm.add(Plate(plateId, departuresForPlate)) + vms[plateId.stop] = vm + if (departures.isEmpty()) + sendResult(plateId, null) + else + sendResult(plateId, departures) + } + + private fun sendResult(plateId: Plate.ID, departures: HashSet<Departure>?) { + val broadcastIntent = Intent() + broadcastIntent.action = ACTION_READY + broadcastIntent.addCategory(Intent.CATEGORY_DEFAULT) + broadcastIntent.putStringArrayListExtra(EXTRA_DEPARTURES, departures?.map { it.toString() } as java.util.ArrayList<String>) + broadcastIntent.putExtra(EXTRA_PLATE_ID, plateId) + sendBroadcast(broadcastIntent) + } + + data class Request(val plate: Plate.ID, var times: Int) + + class EmptyStopSegmentException : Exception() +} + +//note application stops the service on exit + diff --git a/app/src/main/java/ml/adamsprogs/bimba/models/Favourite.kt b/app/src/main/java/ml/adamsprogs/bimba/models/Favourite.kt index db0bd1d7d0464a39473ff0257a1f4f220e781bf2..8a7323530034fe0725df76b3698531708cec54c2 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/models/Favourite.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/models/Favourite.kt @@ -5,7 +5,7 @@ import android.content.Intent import android.os.Parcel import android.os.Parcelable import ml.adamsprogs.bimba.MessageReceiver -import ml.adamsprogs.bimba.VmClient +import ml.adamsprogs.bimba.datasources.VmClient import ml.adamsprogs.bimba.gtfs.AgencyAndId import java.util.* import kotlin.collections.ArrayList diff --git a/app/src/main/java/ml/adamsprogs/bimba/models/Timetable.kt b/app/src/main/java/ml/adamsprogs/bimba/models/Timetable.kt index fe7d7ed87d72398444b185c28a429d48bf32aa70..130b24089a12bbd0c001049115e4a197a97ddfcb 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/models/Timetable.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/models/Timetable.kt @@ -2,7 +2,7 @@ package ml.adamsprogs.bimba.models import android.content.Context import android.database.sqlite.SQLiteDatabase -import ml.adamsprogs.bimba.CacheManager +import ml.adamsprogs.bimba.datasources.CacheManager import ml.adamsprogs.bimba.gtfs.AgencyAndId import ml.adamsprogs.bimba.gtfs.Route import ml.adamsprogs.bimba.gtfs.Trip @@ -245,8 +245,8 @@ val shortName = cursor.getString(2) val longName = cursor.getString(3) val desc = cursor.getString(4) val type = cursor.getInt(5) - val colour = cursor.getInt(6) - val textColour = cursor.getInt(7) + val colour = Integer.parseInt(cursor.getString(6), 16) + val textColour = Integer.parseInt(cursor.getString(7), 16) val (to, from) = desc.split("|") val toSplit = to.split("^") val fromSplit = from.split("^") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cedb2477f8b47ae364493fe4b01f0d797d1bfa9f..08f791cd5383ccb69a47c806a3324f2e1598f9c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,7 @@Valid till %1$s <string name="departure_floor" translatable="false">departure floor type (lowFloor)</string> <string name="departure_info" translatable="false">departure info icon</string> <string name="refreshing_cache">Refreshing cache. May take some time…</string> + <string name="timetable_converting">Converting timetable…</string> <string-array name="daysOfWeek"> <item>Monday</item> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f9748c83eefb1dea8903e81d9cb1bba230c976e5..9519974ec391690efa8cab3744f5951f0ba5019f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -68,4 +68,5 @@- Freitag
<item>Samstag</item> <item>Sontag</item> </string-array> + <string name="timetable_converting">Fahrplan wird umgewandelt…</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1a62255e55c9c2b8f0c4f8de2d16e14eec16406f..37743291599fe9d5d4b0a64fab051653776a0088 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -66,4 +66,5 @@- Venerdi
<item>Sabato</item> <item>Domenica</item> </string-array> + <string name="timetable_converting">L’orario e stando convertito</string> </resources> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 5bb49d14d0a9b36e88a4dc7a93ccf23084c070b7..7d0841a718752fca9fb3cdc3a2cb4f6f1095844e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -68,4 +68,5 @@- Piątek
<item>Sobota</item> <item>Niedziela</item> </string-array> + <string name="timetable_converting">Konwertowanie rozkładu</string> </resources> \ No newline at end of file