Bimba.git

commit 2d6bf46d9ecf8ce011df664fe3a5f91d21ac7734

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