Bimba.git

commit 60d2e029b87347f14b7b93ef25f1173bcd289738

Author: Adam Evyčędo <git@apiote.xyz>

use geonames to geocode short plus codes

%!v(PANIC=String method: strings: negative Repeat count)


diff --git a/app/build.gradle b/app/build.gradle
index f931b1b48dc9a36904514b488590addb09d64851..07698bbf2dd7f2ab28d0183138fdf1d0cda83efd 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,7 +16,7 @@     id "com.google.protobuf" version "0.9.4"
 }
 
 android {
-    compileSdk 34
+    compileSdk 34  // https://gitlab.com/fdroid/fdroiddata/-/issues/3299#note_1989808414
 
     defaultConfig {
         applicationId "xyz.apiote.bimba.czwek"
@@ -49,11 +49,7 @@     buildFeatures {
         viewBinding true
     }
     namespace 'xyz.apiote.bimba.czwek'
-    buildToolsVersion = '34.0.0'
-
-    sourceSets {
-        main.java.srcDirs += 'src/main/proto'
-    }
+    buildToolsVersion = '34.0.0'  // https://gitlab.com/fdroid/fdroiddata/-/issues/3299#note_1989808414
 }
 
 dependencies {
@@ -76,9 +72,13 @@     implementation 'com.otaliastudios:zoomlayout:1.9.0'
     implementation 'dev.bandb.graphview:graphview:0.8.1'
     implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3'
     implementation 'com.github.jershell:kbson:0.5.0'
+    implementation 'androidx.preference:preference-ktx:1.2.1'
+    implementation 'androidx.work:work-runtime-ktx:2.9.0'
+    implementation 'com.github.doyaaaaaken:kotlin-csv-jvm:1.9.3'
+    implementation 'commons-io:commons-io:2.16.1'
+
 
     implementation project(path: ':fruchtfleisch')
-    implementation 'androidx.preference:preference:1.2.0'
 
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
 




diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3cd49cd487e5d6f55d771451b68bbea53a362c70..c7a89b60166547a29e090a5aa32fc59f21652325 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@ 	
 	<uses-permission android:name="android.permission.INTERNET" />
 	<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 	<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+	<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
 
 	<application
 		android:name=".Bimba"




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
index 7ec5dde6fb6c598c55b32452aadb4de2f68380f4..5b7701049711808d7606ecf398e3d60a65f685aa 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
@@ -7,18 +7,16 @@
 import android.Manifest
 import android.content.Intent
 import android.content.pm.PackageManager
-import android.location.Address
-import android.location.Geocoder
 import android.os.Build
 import android.os.Bundle
 import android.view.View
-import android.widget.Toast
 import androidx.activity.OnBackPressedCallback
 import androidx.activity.enableEdgeToEdge
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.app.ActivityCompat
 import androidx.core.content.ContextCompat
 import androidx.core.content.edit
 import androidx.core.view.ViewCompat
@@ -31,6 +29,7 @@ import androidx.fragment.app.FragmentManager
 import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
 import androidx.navigation.fragment.NavHostFragment
 import androidx.navigation.ui.setupWithNavController
+import androidx.preference.PreferenceManager
 import com.google.android.material.bottomnavigation.BottomNavigationView
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.openlocationcode.OpenLocationCode
@@ -159,10 +158,20 @@ 						)
 					)
 						.setTitle(getString(R.string.no_location_access))
 						.setMessage(getString(R.string.no_location_message))
-						.setPositiveButton(R.string.ok) { _, _ ->}
+						.setPositiveButton(R.string.ok) { _, _ -> }
 						.show()
 					locationPermissionDialogShown = true
 				}
+			}
+		}
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+			if (ActivityCompat.checkSelfPermission(
+					this,
+					Manifest.permission.POST_NOTIFICATIONS
+				) != PackageManager.PERMISSION_GRANTED && shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)
+			) {
+				requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
 			}
 		}
 	}
@@ -208,37 +217,39 @@ 	fun onSearchClicked(text: CharSequence?) {
 		if (OpenLocationCode.isValidCode(text.toString())) {
 			val olc = OpenLocationCode(text.toString())
 			if (!olc.isFull) {
-				Toast.makeText(this, getString(R.string.code_is_not_full), Toast.LENGTH_LONG).show()
-				return
+				showResults(ResultsActivity.Mode.MODE_SHORT_CODE_LOCATION, text.toString())
+			} else {
+				val area = olc.decode()
+				showResults(olc.code, area.centerLatitude, area.centerLongitude)
 			}
-			val area = olc.decode()
-			showResults(olc.code, area.centerLatitude, area.centerLongitude)
-		} else if (text.toString().contains(",") && OpenLocationCode.isValidCode(text.toString().split(",").last().trim()) && Geocoder.isPresent()) {
-			val split = text.toString().split(",")
-			val code = split.last().trim()
-			val freePart = split.dropLast(1).joinToString(",")
-			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-				Geocoder(this).getFromLocationName(freePart, 1) {
-					useShortOlc(it, code, text)
+		} else if (OpenLocationCode.isValidCode(
+				text.toString().trim().split(" ").first().trim(',').trim()
+			)
+		) {
+			if (PreferenceManager.getDefaultSharedPreferences(applicationContext)
+					.getLong("cities_last_update", -1) < 0
+			) {
+				if (!PreferenceManager.getDefaultSharedPreferences(applicationContext)
+						.getBoolean("no_geocoding_data_shown", false)
+				) {
+					MaterialAlertDialogBuilder(this)
+						.setIcon(R.drawable.geocoding)
+						.setTitle(R.string.no_geocoding_data)
+						.setMessage(R.string.no_geocoding_data_description)
+						.setPositiveButton(R.string.ok) { _, _ ->
+							showResults(
+								ResultsActivity.Mode.MODE_SEARCH,
+								text.toString()
+							)
+						}
+						.show()
 				}
-			} else  {
-				@Suppress("DEPRECATION")
-				val addresses = Geocoder(this).getFromLocationName(freePart, 1)
-				useShortOlc(addresses, code, text)
+			} else {
+				showResults(ResultsActivity.Mode.MODE_SHORT_CODE, text.toString())
 			}
 		} else {
 			showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString())
 		}
-	}
-
-	private fun useShortOlc(addresses: List<Address>?, code: String, text: CharSequence?) {
-		if (addresses.isNullOrEmpty()) {
-			showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString())
-			return
-		}
-
-		val area = OpenLocationCode(code).recover(addresses[0].latitude, addresses[0].longitude).decode()
-		showResults(text.toString(), area.centerLatitude, area.centerLongitude)
 	}
 
 	private fun showResults(query: String, centerLatitude: Double, centerLongitude: Double) {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
index 8f29cb95067119e2ee24b1688138d347ad55b74b..117278a8722bd5277e13416aeb8abdeea2ea8c24 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
@@ -4,13 +4,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.onboarding
 
+import android.app.NotificationChannel
+import android.app.NotificationManager
 import android.content.Intent
+import android.os.Build
 import android.os.Bundle
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.preference.PreferenceManager
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
 import xyz.apiote.bimba.czwek.repo.migrateDB
+import xyz.apiote.bimba.czwek.settings.DownloadCitiesWorker
 import xyz.apiote.bimba.czwek.settings.feeds.migrateFeedsSettings
+import java.time.Instant
+import java.time.temporal.ChronoUnit
 
 class FirstRunActivity : AppCompatActivity() {
 	override fun onCreate(savedInstanceState: Bundle?) {
@@ -21,6 +31,20 @@ 		val preferences = getSharedPreferences("shp", MODE_PRIVATE)
 
 		migrateFeedsSettings(this)
 		migrateDB(this)
+		createNotificationChannels()
+
+		val (updatesEnabled, weekPassed) = PreferenceManager.getDefaultSharedPreferences(this).let {
+			arrayOf(
+				it.getBoolean("autoupdate_cities_list", false),
+				Instant.ofEpochSecond(it.getLong("cities_last_update", 0)).plus(7, ChronoUnit.DAYS)
+					.isBefore(Instant.now())
+			)
+		}
+
+		if (updatesEnabled && weekPassed) {
+			WorkManager.getInstance(this)
+				.enqueue(OneTimeWorkRequest.from(DownloadCitiesWorker::class.java))
+		}
 
 		val intent = if (preferences.getBoolean("firstRun", true)) {
 			Intent(this, OnboardingActivity::class.java)
@@ -31,4 +55,17 @@ 		startActivity(intent)
 		finish()
 	}
 
+	private fun createNotificationChannels() {
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			val name = getString(R.string.cities_channel_name)
+			val descriptionText = getString(R.string.cities_channel_description)
+			val importance = NotificationManager.IMPORTANCE_LOW
+			val channel = NotificationChannel("cities_channel", name, importance).apply {
+				description = descriptionText
+			}
+			val notificationManager: NotificationManager =
+				getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+			notificationManager.createNotificationChannel(channel)
+		}
+	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
index b6790e7d43cb8f79a365e3bb9b3fa4d5f7bf7e25..9a778552b653033536f19fbbb2deb113457e765b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
@@ -27,6 +27,7 @@ import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updateLayoutParams
 import androidx.core.view.updatePadding
 import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.openlocationcode.OpenLocationCode
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.launch
@@ -42,7 +43,7 @@ import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings
 
 class ResultsActivity : AppCompatActivity(), LocationListener, SensorEventListener {
 	enum class Mode {
-		MODE_LOCATION, MODE_SEARCH, MODE_POSITION
+		MODE_LOCATION, MODE_SEARCH, MODE_POSITION, MODE_SHORT_CODE_LOCATION, MODE_SHORT_CODE
 	}
 
 	private var _binding: ActivityResultsBinding? = null
@@ -54,6 +55,7 @@ 	private val handler = Handler(Looper.getMainLooper())
 	private var runnable = Runnable {}
 	private var gravity: FloatArray? = null
 	private var geomagnetic: FloatArray? = null
+	private var shortOLC: OpenLocationCode? = null
 
 	override fun onCreate(savedInstanceState: Bundle?) {
 		enableEdgeToEdge()
@@ -88,6 +90,30 @@ 				supportActionBar?.title = getString(R.string.stops_nearby)
 				locate()
 			}
 
+			Mode.MODE_SHORT_CODE_LOCATION -> {
+				val query = intent.extras?.getString("query")
+				getString(R.string.stops_near_code, query)
+				shortOLC = OpenLocationCode(query)
+				locate()
+			}
+
+			Mode.MODE_SHORT_CODE -> {
+				val query = intent.extras?.getString("query")
+				val split = query!!.trim().split(" ")
+				val code = split.first().trim(',').trim()
+				val freePart = split.drop(1).joinToString(" ")
+				val location = findPlace(this, freePart)
+				if (location == null) {
+					showError(Error(0, R.string.error_geocoding, R.drawable.geocoding))
+				} else {
+					val area = OpenLocationCode(code).recover(location.latitude, location.longitude).decode()
+					getQueryablesByLocation(Location(null).apply {
+						latitude = area.centerLatitude
+						longitude = area.centerLongitude
+					}, this)
+				}
+			}
+
 			Mode.MODE_POSITION -> {
 				val query = intent.extras?.getString("query")
 				val lat = intent.extras?.getDouble("lat")
@@ -142,7 +168,15 @@ 	}
 
 	override fun onLocationChanged(location: Location) {
 		handler.removeCallbacks(runnable)
-		getQueryablesByLocation(location, this, true)
+		val area = shortOLC?.recover(location.latitude, location.longitude)?.decode()
+		if (area != null) {
+			getQueryablesByLocation(Location(null).apply {
+				latitude = area.centerLatitude
+				longitude = area.centerLongitude
+			}, this, false)
+		} else {
+			getQueryablesByLocation(location, this, true)
+		}
 	}
 
 	override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
@@ -155,7 +189,7 @@ 			val success = SensorManager.getRotationMatrix(r, FloatArray(9), gravity, geomagnetic)
 			if (success) {
 				val orientation = FloatArray(3)
 				SensorManager.getOrientation(r, orientation)
-				adapter.update((orientation[0]*180/Math.PI).toFloat())
+				adapter.update((orientation[0] * 180 / Math.PI).toFloat())
 			}
 		}
 	}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7fd75e381c417517d76ecd5d8b2f68ac68718d0a
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt
@@ -0,0 +1,27 @@
+package xyz.apiote.bimba.czwek.search
+
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import android.location.Location
+
+fun findPlace(context: Context, name: String): Location? {
+	val db = SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("geocoding").path, null)
+	val cursor = db.rawQuery(
+		"select lat, lon from place_names join places using(id) where name = ?",
+		arrayOf(name)
+	)
+
+	if (!cursor.moveToNext()) {
+		cursor.close()
+		db.close()
+		return null
+	}
+
+	val location = Location(null).apply {
+		latitude = cursor.getDouble(0)
+		longitude = cursor.getDouble(1)
+	}
+	cursor.close()
+	db.close()
+	return location
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/DownloadCitiesWorker.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/DownloadCitiesWorker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..02de1d344e0666614ee02207fe90531cd3cb5626
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/DownloadCitiesWorker.kt
@@ -0,0 +1,221 @@
+package xyz.apiote.bimba.czwek.settings
+
+import android.Manifest
+import android.app.NotificationManager
+import android.content.Context
+import android.content.pm.PackageManager
+import android.database.sqlite.SQLiteDatabase
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.edit
+import androidx.core.database.sqlite.transaction
+import androidx.preference.PreferenceManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
+import org.apache.commons.io.input.BoundedInputStream
+import xyz.apiote.bimba.czwek.R
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+import java.net.URL
+import java.time.ZonedDateTime
+import java.util.UUID
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+class DownloadCitiesWorker(appContext: Context, workerParams: WorkerParameters) :
+	Worker(appContext, workerParams) {
+
+	override fun doWork(): Result {
+		val notificationBuilder = NotificationCompat.Builder(applicationContext, "cities_channel")
+			.setSmallIcon(R.drawable.geocoding)
+			.setContentTitle(applicationContext.getString(R.string.updating_geocoding_data))
+			.setContentText(applicationContext.getString(R.string.downloading_cities_list))
+			.setPriority(NotificationCompat.PRIORITY_LOW)
+			.setProgress(100, 0, true)
+		try {
+			if (ActivityCompat.checkSelfPermission(
+					applicationContext,
+					Manifest.permission.POST_NOTIFICATIONS
+				) == PackageManager.PERMISSION_GRANTED
+			) {
+				NotificationManagerCompat.from(applicationContext).notify(0, notificationBuilder.build())
+			}
+
+			val db = SQLiteDatabase.openOrCreateDatabase(
+				applicationContext.getDatabasePath("geocoding").path,
+				null
+			)
+			val url = URL("https://download.geonames.org/export/dump/cities15000.zip")
+			val connection = url.openConnection()
+			var length = connection.contentLength.toLong()
+			val connectionEtag = connection.getHeaderField("ETag")
+			val savedEtag = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+				.getString("cities_etag", null)
+			if (savedEtag != null && savedEtag == connectionEtag) {
+				if (ActivityCompat.checkSelfPermission(
+						applicationContext,
+						Manifest.permission.POST_NOTIFICATIONS
+					) == PackageManager.PERMISSION_GRANTED
+				) {
+					NotificationManagerCompat.from(applicationContext).cancel(0)
+				}
+				return Result.success()
+			}
+
+			db.execSQL("drop table if exists place_names2")
+			db.execSQL("drop table if exists places2")
+			db.execSQL("create table places2(id text primary key, lat real, lon real)")
+			db.execSQL("create table place_names2(id text references places(id), name text primary key)")
+
+			var countingStream =
+				BoundedInputStream.Builder()
+					.setInputStream(BufferedInputStream(connection.getInputStream())).get()
+			val zipFileStream = BufferedOutputStream(
+				File(
+					applicationContext.noBackupFilesDir.path,
+					"cities.zip"
+				).outputStream()
+			)
+
+			val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+			var bytes = countingStream.read(buffer)
+			while (bytes >= 0) {
+				zipFileStream.write(buffer, 0, bytes)
+				Log.i(
+					"geocoding",
+					"zip_download: downloaded ${countingStream.count}/$length: ${countingStream.count.toFloat() / length * 100}%"
+				)
+				if (ActivityCompat.checkSelfPermission(
+						applicationContext,
+						Manifest.permission.POST_NOTIFICATIONS
+					) == PackageManager.PERMISSION_GRANTED
+				) {
+					notificationBuilder
+						.setProgress(100, (countingStream.count.toFloat() / length * 100).toInt(), false)
+					NotificationManagerCompat.from(applicationContext).notify(0, notificationBuilder.build())
+				}
+				bytes = countingStream.read(buffer)
+			}
+			countingStream.close()
+			zipFileStream.close()
+
+			notificationBuilder
+				.setProgress(100, 0, true)
+				.setContentText(applicationContext.getString(R.string.saving_cities_list))
+			if (ActivityCompat.checkSelfPermission(
+					applicationContext,
+					Manifest.permission.POST_NOTIFICATIONS
+				) == PackageManager.PERMISSION_GRANTED
+			) {
+				NotificationManagerCompat.from(applicationContext).notify(0, notificationBuilder.build())
+			}
+			val zipFile = File(applicationContext.noBackupFilesDir.path, "cities.zip")
+			length = zipFile.length()
+			countingStream =
+				BoundedInputStream.Builder().setInputStream(BufferedInputStream(zipFile.inputStream()))
+					.get()
+			val stream = ZipInputStream(countingStream)
+			var entry: ZipEntry? = stream.nextEntry
+			while (entry != null) {
+				if (entry.name != "cities15000.txt") {
+					entry = stream.nextEntry
+					continue
+				}
+				var count = 0
+				db.transaction {
+					csvReader { delimiter = '\t' }.open(stream) {
+						readAllAsSequence().forEach { row ->
+							val names = if (row[3] == "") {
+								"${row[1]},${row[2]}"
+							} else {
+								row[3]
+							}
+							if (count % 1000 == 0) {
+								Log.i(
+									"geocoding",
+									"${countingStream.count}/$length=${countingStream.count.toFloat() / length * 100}% $names"
+								)
+								if (ActivityCompat.checkSelfPermission(
+										applicationContext,
+										Manifest.permission.POST_NOTIFICATIONS
+									) == PackageManager.PERMISSION_GRANTED
+								) {
+									notificationBuilder
+										.setProgress(
+											100,
+											(countingStream.count.toFloat() / length * 100).toInt(),
+											false
+										)
+									NotificationManagerCompat.from(applicationContext)
+										.notify(0, notificationBuilder.build())
+								}
+							}
+							count++
+
+							val id = UUID.randomUUID()
+							db.execSQL("insert into places2 values(?, ?, ?)", arrayOf(id, row[4], row[5]))
+							names.split(",").toSet().forEach { name ->
+								db.execSQL(
+									"insert into place_names2 values(?, ?) on conflict(name) do nothing",
+									arrayOf(id, name)
+								)
+								db.execSQL(
+									"insert into place_names2 values(?, ?) on conflict(name) do nothing",
+									arrayOf(id, "$name, ${row[8]}")
+								)
+							}
+						}
+					}
+				}
+				Log.i("geocoding", "COMPLETE")
+				break
+			}
+			stream.close()
+			zipFile.delete()
+
+			db.execSQL("drop index if exists place_names__name")
+			db.execSQL("drop table if exists place_names")
+			db.execSQL("drop table if exists places")
+			db.execSQL("alter table places2 rename to places")
+			db.execSQL("alter table place_names2 rename to place_names")
+			db.execSQL("create unique index place_names__name on place_names(name)")
+
+			PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
+				putLong("cities_last_update", ZonedDateTime.now().toEpochSecond())
+				putString("cities_etag", connectionEtag)
+			}
+
+			db.close()
+			if (ActivityCompat.checkSelfPermission(
+					applicationContext,
+					Manifest.permission.POST_NOTIFICATIONS
+				) == PackageManager.PERMISSION_GRANTED
+			) {
+				notificationBuilder
+					.setContentText("")
+					.setContentTitle(applicationContext.getString(R.string.finished_updating_geocoding_data))
+					.setProgress(100, 100, false)
+				NotificationManagerCompat.from(applicationContext).notify(0, notificationBuilder.build())
+			}
+			return Result.success()
+		} catch (e: Exception) {
+			e.printStackTrace()
+			if (ActivityCompat.checkSelfPermission(
+					applicationContext,
+					Manifest.permission.POST_NOTIFICATIONS
+				) == PackageManager.PERMISSION_GRANTED
+			) {
+				notificationBuilder
+					.setContentText("")
+					.setContentTitle(applicationContext.getString(R.string.updating_geocoding_data_failed))
+					.setProgress(100, 100, false)
+				NotificationManagerCompat.from(applicationContext).notify(0, notificationBuilder.build())
+			}
+			return Result.failure()
+		}
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/SettingsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/SettingsActivity.kt
index be458c29a7f4d36e99f1e0d0189c4eb7879aa16e..9b5fc9f9d366eacf9d9bcbeee9f5dc7e38268986 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/SettingsActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/SettingsActivity.kt
@@ -1,6 +1,8 @@
 package xyz.apiote.bimba.czwek.settings
 
+import android.content.Context
 import android.os.Bundle
+import android.util.Log
 import android.view.View
 import android.view.ViewGroup
 import androidx.activity.enableEdgeToEdge
@@ -8,8 +10,22 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updateLayoutParams
+import androidx.preference.Preference
 import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.PreferenceManager
+import androidx.preference.SwitchPreferenceCompat
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
 import xyz.apiote.bimba.czwek.R
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME
+import java.util.concurrent.ExecutionException
+
 
 class SettingsActivity : AppCompatActivity() {
 
@@ -24,7 +40,6 @@ 				.replace(R.id.settings, SettingsFragment())
 				.commit()
 		}
 		supportActionBar?.setDisplayHomeAsUpEnabled(true)
-		// TODO insets
 
 		val root = findViewById<View>(R.id.settings)
 
@@ -40,6 +55,52 @@
 	class SettingsFragment : PreferenceFragmentCompat() {
 		override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
 			setPreferencesFromResource(R.xml.root_preferences, rootKey)
+
+			findPreference<Preference>("download_cities_list")?.setOnPreferenceClickListener {
+				val request = OneTimeWorkRequestBuilder<DownloadCitiesWorker>()
+					.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+					.build()
+				WorkManager.getInstance(requireContext())
+					.enqueueUniqueWork("download_cities", ExistingWorkPolicy.KEEP, request)
+				findPreference<Preference>("download_cities_list")?.isEnabled = false
+				true
+			}
+
+			if (isWorkScheduled(requireContext(), "download_cities")) {
+				findPreference<Preference>("download_cities_list")?.isEnabled = false // TODO more reliable
+			}
+
+			val citiesLastUpdate = PreferenceManager.getDefaultSharedPreferences(requireContext())
+				.getLong("cities_last_update", -1)
+			Log.i("geocoding", "$citiesLastUpdate")
+			if (citiesLastUpdate > 0) {
+				// TODO localise
+				val lastUpdateTime =
+					ZonedDateTime.ofInstant(Instant.ofEpochSecond(citiesLastUpdate), ZoneId.systemDefault())
+						.format(RFC_1123_DATE_TIME)
+				findPreference<SwitchPreferenceCompat>("autoupdate_cities_list")?.summary =
+					"Last update $lastUpdateTime"
+			}
+		}
+
+		private fun isWorkScheduled(context: Context, name: String): Boolean {
+			val instance = WorkManager.getInstance(context)
+			val statuses = instance.getWorkInfosForUniqueWork(name)
+			try {
+				var running = false
+				val workInfoList = statuses.get()
+				for (workInfo in workInfoList) {
+					val state: WorkInfo.State = workInfo.state
+					running = (state == WorkInfo.State.RUNNING) or (state == WorkInfo.State.ENQUEUED)
+				}
+				return running
+			} catch (e: ExecutionException) {
+				e.printStackTrace()
+				return false
+			} catch (e: InterruptedException) {
+				e.printStackTrace()
+				return false
+			}
 		}
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml
new file mode 100644
index 0000000000000000000000000000000000000000..287b023df7d8e7aceb629b78d32c3048d63636aa
--- /dev/null
+++ b/app/src/main/res/drawable/download.xml
@@ -0,0 +1,16 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:tint="?attr/colorOnSurface"
+	android:viewportWidth="24"
+	android:viewportHeight="24">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z" />
+</vector>




diff --git a/app/src/main/res/drawable/download_black.xml b/app/src/main/res/drawable/download_black.xml
deleted file mode 100644
index 460eafe98b28c5f4230ff2d599cd098d373acb67..0000000000000000000000000000000000000000
--- a/app/src/main/res/drawable/download_black.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-<!--
-SPDX-FileCopyrightText: Google
-
-SPDX-License-Identifier: Apache-2.0
--->
-
-<vector android:height="24dp" android:tint="#000000"
-    android:viewportHeight="24" android:viewportWidth="24"
-    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
-    <path android:fillColor="@android:color/white" android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z"/>
-</vector>




diff --git a/app/src/main/res/drawable/geocoding.xml b/app/src/main/res/drawable/geocoding.xml
new file mode 100644
index 0000000000000000000000000000000000000000..79eaee48d8d2f42d73cb6bdb8e0ae88ff8183512
--- /dev/null
+++ b/app/src/main/res/drawable/geocoding.xml
@@ -0,0 +1,15 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:tint="?attr/colorOnSurface"
+	android:viewportWidth="960"
+	android:viewportHeight="960">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q626,80 735.5,171.5Q845,263 872,401L790,401Q771,328 721.5,270.5Q672,213 600,184L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L400,480L400,600L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,611 252,705Q344,799 480,800L480,880ZM844,860L716,732Q695,744 671,752Q647,760 620,760Q545,760 492.5,707.5Q440,655 440,580Q440,505 492.5,452.5Q545,400 620,400Q695,400 747.5,452.5Q800,505 800,580Q800,607 792,631Q784,655 772,676L900,804L844,860ZM620,680Q662,680 691,651Q720,622 720,580Q720,538 691,509Q662,480 620,480Q578,480 549,509Q520,538 520,580Q520,622 549,651Q578,680 620,680Z" />
+</vector>
\ No newline at end of file




diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml
new file mode 100644
index 0000000000000000000000000000000000000000..81dbed5fa3d03152beba444b8f9712edee421b84
--- /dev/null
+++ b/app/src/main/res/drawable/sync.xml
@@ -0,0 +1,17 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:tint="?attr/colorOnSurface"
+	android:viewportWidth="24"
+	android:viewportHeight="24">
+
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M12,4L12,1L8,5l4,4L12,6c3.31,0 6,2.69 6,6 0,1.01 -0.25,1.97 -0.7,2.8l1.46,1.46C19.54,15.03 20,13.57 20,12c0,-4.42 -3.58,-8 -8,-8zM12,18c-3.31,0 -6,-2.69 -6,-6 0,-1.01 0.25,-1.97 0.7,-2.8L5.24,7.74C4.46,8.97 4,10.43 4,12c0,4.42 3.58,8 8,8v3l4,-4 -4,-4v3z" />
+
+</vector>




diff --git a/app/src/main/res/layout/preferences_switch_material.xml b/app/src/main/res/layout/preferences_switch_material.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9bd2f24892abc6f705c6ce92d4392c206e2a9855
--- /dev/null
+++ b/app/src/main/res/layout/preferences_switch_material.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<!-- Derived from https://github.com/androidx/androidx/blob/005e9694795cee9a42375d80b0d813af9e700ac1/preference/preference/res/layout/preference_widget_switch_compat.xml -->
+<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
+	android:id="@+id/switchWidget"
+	android:layout_width="wrap_content"
+	android:layout_height="wrap_content"
+	android:background="@null"
+	android:clickable="false"
+	android:focusable="false" />
\ No newline at end of file




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9318bde41054eae756a82ecd5ff0e04c004189af..46b385efeb43d4d3eedd555983a0cc5383a1fe59 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,7 +6,7 @@ -->
 
 <!-- NOTE base strings are in en-GB -->
 
-<resources>
+<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
 	<string name="app_name">Bimba</string>
 	<string name="title_home">Home</string>
 	<string name="title_map">Map</string>
@@ -163,7 +163,7 @@ 	Stop on demand
 	<string name="stop_stub_in_zone">Stop in zone %1$s</string>
 	<string name="vehicle_headsign_content_description">%1$s towards %2$s</string>
 	<string name="departure_headsign">» %1$s</string>
-	<string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) based on Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n Mastodon icon (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Bimba logo created by https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Map data © OpenStreetMap contributors (https://www.openstreetmap.org/copyright), ODbL-1.0</string>
+	<string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) based on Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n Mastodon icon (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Bimba logo created by https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Map data © OpenStreetMap contributors (https://www.openstreetmap.org/copyright), ODbL-1.0\n\n Cities list used for geocoding short plus codes © Geonames (https://geonames.org), CC BY</string>
 	<string name="title_about">About</string>
 	<string name="translation_button_description">link to translations service</string>
 	<string name="app_description">FLOSS public transport passenger companion; a timetable in your pocket.</string>
@@ -208,4 +208,14 @@ 	US Customary
 	<string name="units_tgm10">TGM (base 10)</string>
 	<string name="units_tgm12">TGM (base 12)</string>
 	<string name="title_settings">Settings</string>
+	<string name="no_geocoding_data">No geocoding data</string>
+	<string name="no_geocoding_data_description">The query contains a short plus code but there is no geocoding data present. Download geocoding data or enable auto updating in settings.</string>
+	<string name="error_geocoding">City not found</string>
+	<string name="cities_channel_name">Cities update channel</string>
+	<string name="cities_channel_description">Notifications showing progress of updating geocoding local data</string>
+	<string name="saving_cities_list">saving cities list</string>
+	<string name="updating_geocoding_data">Updating geocoding data</string>
+	<string name="downloading_cities_list">downloading cities list</string>
+	<string name="finished_updating_geocoding_data">Finished updating geocoding data</string>
+	<string name="updating_geocoding_data_failed">Updating geocoding data failed</string>
 </resources>




diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 2ed4f97f64d1cafcc78e7558ca4486830dc8c3ea..042048b10e8ca72c5956032013ac943358cfae06 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -43,7 +43,7 @@ 		.2
 	</style>
 
 	<declare-styleable name="Theme.Bimba">
-		<attr name="randomColourLightness" format="float"/>
+		<attr name="randomColourLightness" format="float" />
 		<attr name="lightStatusBar" format="boolean" />
 	</declare-styleable>
 
@@ -73,5 +73,9 @@
 	<style name="roundedImageView" parent="">
 		<item name="cornerFamily">rounded</item>
 		<item name="cornerSize">24dp</item>
+	</style>
+
+	<style name="Preference.SwitchPreferenceCompat" parent="@style/Preference.SwitchPreferenceCompat.Material" tool:ignore="ResourceCycle">
+		<item name="widgetLayout">@layout/preferences_switch_material</item>
 	</style>
 </resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index e97857fb100d4f924cc140f36f4fb2c5e36955e1..18cc0e701931c44646e7e0c5b028a021e85a355c 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -51,4 +51,9 @@