Bimba.git

commit 608fe617a7d2fc453fc6fbd3d22fb29c17e42058

Author: Adam <git@apiote.xyz>

handle network errors

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


diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 38569329b00c22228dc59bda0840bcf4d5301eed..6e343e6079130d4cf20af425b7b735f9fa65c8ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@  	xmlns:tools="http://schemas.android.com/tools"
 	package="ml.adamsprogs.bimba">
 
+	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
 	<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" />




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
index 6786b6a8fe2f3ee6a38d00b7b623fdddda9aee8d..d4ef93183cd94fa5f9fc54d150ea130c42535c54 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
@@ -1,51 +1,78 @@
 package ml.adamsprogs.bimba.api
 
-import android.util.Log
+import android.net.ConnectivityManager
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
+import ml.adamsprogs.bimba.R
+import java.io.IOException
 import java.io.InputStream
 import java.net.HttpURLConnection
 import java.net.URL
 import java.net.URLEncoder
 
 data class Server(val host: String, val token: String, val feeds: String)
+
+data class Result(val stream: InputStream?, val error: Error?)
+
+data class Error(val statusCode: Int, val stringResource: Int)
 
 @Suppress("BlockingMethodInNonBlockingContext")
-suspend fun getFeeds(server: Server): InputStream? {  // todo(error-handling) if 401 then needs token
-	return rawRequest(URL("${hostWithScheme(server.host)}/api/"), server)
+suspend fun getFeeds(cm: ConnectivityManager, server: Server): Result {
+	return rawRequest(URL("${hostWithScheme(server.host)}/api/"), server, cm)  // todo(error-handling) if is not a valid URL
 }
 
-suspend fun queryItems(server: Server, query: String, limit: Int? = null): InputStream? {
+suspend fun queryItems(cm: ConnectivityManager, server: Server, query: String, limit: Int? = null): Result {
 	val params = mutableMapOf("q" to query)
 	if (limit != null) {
 		params["limit"] = limit.toString()
 	}
-	return request(server, "items", params)
+	return request(server, "items", params, cm)
 }
 
-suspend fun locateItems(server: Server, plusCode: String): InputStream? {
-	return request(server, "items", mapOf("near" to plusCode))
+suspend fun locateItems(cm:ConnectivityManager, server: Server, plusCode: String): Result {
+	return request(server, "items", mapOf("near" to plusCode), cm)
 }
 
-suspend fun getDepartures(server: Server, stop: String, line: String? = null): InputStream? {
+suspend fun getDepartures(cm: ConnectivityManager, server: Server, stop: String, line: String? = null): Result {
 	val params = mutableMapOf("code" to stop)
 	if (line != null) {
 		params["line"] = line
 	}
-	return request(server, "departures", params)
+	return request(server, "departures", params, cm)
 }
 
 @Suppress("BlockingMethodInNonBlockingContext")
-suspend fun rawRequest(url: URL, server: Server): InputStream? {
+suspend fun rawRequest(url: URL, server: Server, cm: ConnectivityManager): Result {
+	@Suppress("DEPRECATION")  // fix_later(API_29, API_23) https://developer.android.com/reference/android/net/ConnectivityManager#getActiveNetwork()
+	if (cm.activeNetworkInfo == null) {
+		// todo check false-positives
+		return Result(null, Error(0, R.string.error_offline))
+	}
 	return withContext(Dispatchers.IO) {
 		val c = (url.openConnection() as HttpURLConnection).apply {
 			setRequestProperty("X-Bimba-Token", server.token)
 		}
 		try {
-			c.inputStream
-		} catch (e: Exception) {
-			Log.e("request", e.stackTraceToString())
-			null
+			if (c.responseCode == 200) {
+				Result(c.inputStream, null)
+			} else {
+				val string = when (c.responseCode) {
+					400 -> R.string.error_400
+					401 -> R.string.error_401
+					403 -> R.string.error_403
+					404 -> R.string.error_404 // todo check if server returns 404
+					429 -> R.string.error_429
+					500 -> R.string.error_50x
+					502 -> R.string.error_50x
+					503 -> R.string.error_50x
+					504 -> R.string.error_50x
+					else -> R.string.error_unknown
+				}
+				Result(c.errorStream, Error(c.responseCode, string))
+			}
+		} catch (e: IOException) {
+			// todo timeout, no Internet connection
+			Result(null, Error(0, R.string.error_connecting))
 		}
 	}
 }
@@ -54,8 +81,9 @@ @Suppress("BlockingMethodInNonBlockingContext")
 suspend fun request(
 	server: Server,
 	resource: String,
-	params: Map<String, String>
-): InputStream? {
+	params: Map<String, String>,
+	cm: ConnectivityManager
+): Result {
 	return withContext(Dispatchers.IO) {
 		val url = URL(
 			"${hostWithScheme(server.host)}/api/${server.feeds}/$resource${
@@ -69,7 +97,7 @@ 					}"
 				}.joinToString("&", "?")
 			}"
 		)
-		rawRequest(url, server)
+		rawRequest(url, server, cm)
 	}
 }
 




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
index 527f9975d70cd1b6a4dc629b3dd6ee8718eda5d0..6ebdece24a398fb5511291697275703729ccc6bd 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
@@ -5,14 +5,14 @@ import java.io.InputStream
 
 interface DeparturesResponse {
 	companion object {
-		fun unmarshall(stream: InputStream): DeparturesResponse {
+		fun unmarshal(stream: InputStream): DeparturesResponse {
 			val reader = Reader(stream)
-			when (reader.readUInt()) {
+			return when (reader.readUInt()) {
 				0UL -> {
-					TODO("error response")
+					ErrorResponse.unmarshal(stream)
 				}
 				1UL -> {
-					return DeparturesSuccess.unmarshall(stream)
+					DeparturesSuccess.unmarshal(stream)
 				}
 				else -> {
 					TODO("throw unknown tag")
@@ -28,7 +28,7 @@ 	val departures: List,
 	val stop: Stop
 ) : DeparturesResponse {
 	companion object {
-		fun unmarshall(stream: InputStream): DeparturesSuccess {
+		fun unmarshal(stream: InputStream): DeparturesSuccess {
 			val alerts = mutableListOf<Alert>()
 			val departures = mutableListOf<Departure>()
 
@@ -51,14 +51,14 @@ }
 
 interface ItemsResponse {
 	companion object {
-		fun unmarshall(stream: InputStream): ItemsResponse {
+		fun unmarshal(stream: InputStream): ItemsResponse {
 			val reader = Reader(stream)
-			when (reader.readUInt()) {
+			return when (reader.readUInt()) {
 				0UL -> {
-					TODO("error response")
+					ErrorResponse.unmarshal(stream)
 				}
 				1UL -> {
-					return ItemsSuccess.unmarshall(stream)
+					ItemsSuccess.unmarshal(stream)
 				}
 				else -> {
 					TODO("throw unknown tag")
@@ -70,7 +70,7 @@ }
 
 data class ItemsSuccess(val items: List<Item>) : ItemsResponse {
 	companion object {
-		fun unmarshall(stream: InputStream): ItemsSuccess {
+		fun unmarshal(stream: InputStream): ItemsSuccess {
 			val items = mutableListOf<Item>()
 			val reader = Reader(stream)
 			val itemsNum = reader.readUInt()
@@ -96,12 +96,12 @@ interface FeedsResponse {
 	companion object {
 		fun unmarshal(stream: InputStream): FeedsResponse {
 			val reader = Reader(stream)
-			when (reader.readUInt()) {
+			return when (reader.readUInt()) {
 				0UL -> {
-					TODO("error response")
+					ErrorResponse.unmarshal(stream)
 				}
 				1UL -> {
-					return FeedsSuccess.unmarshal(stream)
+					FeedsSuccess.unmarshal(stream)
 				}
 				else -> {
 					TODO("throw unknown tag")
@@ -133,6 +133,11 @@ 		}
 	}
 }
 
-data class Error(val message: String) : ItemsResponse, DeparturesResponse, FeedsResponse {
-
+data class ErrorResponse(val field: String, val message: String) : ItemsResponse, DeparturesResponse, FeedsResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): ErrorResponse {
+			val reader = Reader(stream)
+			return ErrorResponse(reader.readString(), reader.readString())
+		}
+	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
index 049677ff850b90b70b9f64f5af0b5d9dac9dc8be..321dae34daa24c2f563b4452185ec9defe9fa59f 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
@@ -1,5 +1,7 @@
 package ml.adamsprogs.bimba.dashboard.ui.home
 
+import android.content.Context
+import android.net.ConnectivityManager
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
@@ -34,9 +36,11 @@ 			binding.searchBar.updateLastSuggestions(it)
 		}
 
 		binding.searchBar.lastSuggestions = lastSuggestions
+		val cm = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		binding.searchBar.addTextChangeListener(
 			homeViewModel.SearchBarWatcher(
-				requireContext().getSharedPreferences("shp", AppCompatActivity.MODE_PRIVATE)
+				requireContext().getSharedPreferences("shp", AppCompatActivity.MODE_PRIVATE),
+				cm
 			)
 		)
 		binding.searchBar.setOnSearchActionListener(object : MaterialSearchBar.OnSearchActionListener {




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
index b50c66cf634c39ea3fe9be0939c8ee6f29334380..c1bc4e04500f5a7bb2c82ba34090c5b5b142372a 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
@@ -1,10 +1,12 @@
 package ml.adamsprogs.bimba.dashboard.ui.home
 
 import android.content.SharedPreferences
+import android.net.ConnectivityManager
 import android.os.Handler
 import android.os.Looper
 import android.text.Editable
 import android.text.TextWatcher
+import android.util.Log
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
@@ -19,29 +21,33 @@ class HomeViewModel : ViewModel() {
 	private val mutableItems = MutableLiveData<List<Item>>()
 	val items: LiveData<List<Item>> = mutableItems
 
-	fun getItems(server: Server, query: String) {
+	fun getItems(cm: ConnectivityManager, server: Server, query: String) {
 		viewModelScope.launch {
-			val itemsStream = queryItems(server, query, limit = 6)
-			if (itemsStream != null) {
-				mutableItems.value = unmarshallItemResponse(itemsStream)
+			val itemsResult = queryItems(cm, server, query, limit = 6)
+			if (itemsResult.stream == null) {
+				Log.e("HVM.getItems", "$itemsResult")
+			} else {
+				val response = unmarshallItemResponse(itemsResult.stream)
+				if (itemsResult.error == null) {
+					mutableItems.value = (response as ItemsSuccess).items
+				} else {
+					Log.w("HVM.getItems", "$itemsResult")
+					Log.w("HVM.getItems", "$response")
+				}
 			}
 		}
 	}
 
-	private suspend fun unmarshallItemResponse(stream: InputStream): List<Item> {
+	private suspend fun unmarshallItemResponse(stream: InputStream): ItemsResponse {
 		return withContext(Dispatchers.IO) {
-			when (val response = ItemsResponse.unmarshall(stream)) {
-				is ItemsSuccess -> {
-					return@withContext response.items
-				}
-				else -> {
-					TODO("Error response")
-				}
-			}
+			ItemsResponse.unmarshal(stream)
 		}
 	}
 
-	inner class SearchBarWatcher(private val preferences: SharedPreferences) :
+	inner class SearchBarWatcher(
+		private val preferences: SharedPreferences,
+		private val cm: ConnectivityManager
+	) :
 		TextWatcher {
 		private val handler = Handler(Looper.getMainLooper())
 		private var workRunnable = Runnable {}
@@ -58,6 +64,7 @@ 			workRunnable = Runnable {
 				val host = preferences.getString("host", "bimba.apiote.xyz")!!
 				val text = s.toString()
 				getItems(
+					cm,
 					Server(
 						host, preferences.getString("token", "")!!,
 						preferences.getString("${host}_feeds", "")!!




diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
index 666cdb9e1c0aee708183d998f3430df9e6c4eba9..762cb5ae966e261c605cb625076a9269b942d0cb 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
@@ -3,11 +3,11 @@
 import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
+import android.net.ConnectivityManager
 import androidx.appcompat.app.AppCompatActivity
 import android.os.Bundle
 import android.util.Log
 import android.view.View
-import android.widget.Toast
 import androidx.core.content.res.ResourcesCompat
 import androidx.core.view.WindowCompat
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -72,37 +72,33 @@ 		}
 	}
 
 	private fun getDepartures() {
+		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		val host = preferences.getString("host", "bimba.apiote.xyz")!!
 		MainScope().launch {
-			val departuresStream = getDepartures(
+			val departuresResult = getDepartures(
+				cm,
 				Server(
 					host, preferences.getString("token", "")!!,
 					preferences.getString("${host}_feeds", "")!!
 				), getCode()
 			)
-			if (departuresStream == null) {
-				// todo(error-handling) show empty state
-				Toast.makeText(
-					this@DeparturesActivity as Context,
-					"Couldn't get response",
-					Toast.LENGTH_SHORT
-				).show()
+			val response = if (departuresResult.stream != null) {
+				unmarshallDepartureResponse(departuresResult.stream)
 			} else {
-				updateItems(unmarshallDepartureResponse(departuresStream))
+				null
+			}
+			if (departuresResult.error != null) {
+				Log.e("Departures", "$departuresResult")
+				Log.e("Departures", "$response")
+			} else {
+				updateItems((response as DeparturesSuccess))
 			}
 		}
 	}
 
-	private suspend fun unmarshallDepartureResponse(stream: InputStream): DeparturesSuccess {
+	private suspend fun unmarshallDepartureResponse(stream: InputStream): DeparturesResponse {
 		return withContext(Dispatchers.IO) {
-			when (val response = DeparturesResponse.unmarshall(stream)) {
-				is DeparturesSuccess -> {
-					return@withContext response
-				}
-				else -> {
-					TODO("Error response")
-				}
-			}
+			DeparturesResponse.unmarshal(stream)
 		}
 	}
 




diff --git a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt
index 9767a3d6f98f960e9219e0b0338197ce8a7d6789..ee75453e0014bd89d67b00c811ab49b736e6e910 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt
@@ -3,11 +3,11 @@
 import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
+import android.net.ConnectivityManager
 import androidx.appcompat.app.AppCompatActivity
 import android.os.Bundle
 import android.util.Log
 import android.view.View
-import android.widget.Toast
 import androidx.core.content.edit
 import androidx.core.widget.addTextChangedListener
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -87,16 +87,19 @@ 		binding.resultsRecycler.visibility = View.GONE
 		binding.feedInfo.visibility = View.GONE
 
 		MainScope().launch {
-			val feedsStream = getFeeds(Server(host, token, ""))
-			if (feedsStream == null) {
-				// todo(error-handling) show empty state
-				Toast.makeText(
-					this@FeedChooserActivity as Context,
-					"Couldn't get response",
-					Toast.LENGTH_SHORT
-				).show()
+			val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+			val feedsResult = getFeeds(cm, Server(host, token, ""))
+			val response = if (feedsResult.stream != null) {
+				unmarshallFeedsResponse(feedsResult.stream)
+			} else {
+				null
+			}
+			if (feedsResult.error != null) {
+				binding.feedInfo.text = getString(feedsResult.error.stringResource)
+				Log.w("FeedChooser", "$feedsResult")
+				Log.w("FeedChooser", "$response")
 			} else {
-				updateItems(unmarshallFeedsResponse(feedsStream))
+				updateItems(response as FeedsSuccess)  // todo(error-handling) handle parsing error (not bimba server, wrong API version)
 				binding.button.apply {
 					text = context.getString(R.string.save)
 					setOnClickListener {
@@ -121,16 +124,9 @@ 			finish()
 		}
 	}
 
-	private suspend fun unmarshallFeedsResponse(stream: InputStream): FeedsSuccess {
+	private suspend fun unmarshallFeedsResponse(stream: InputStream): FeedsResponse {
 		return withContext(Dispatchers.IO) {
-			when (val response = FeedsResponse.unmarshal(stream)) {
-				is FeedsSuccess -> {
-					return@withContext response
-				}
-				else -> {
-					TODO("Error response")
-				}
-			}
+			FeedsResponse.unmarshal(stream)
 		}
 	}
 




diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
index 70ed1056b424052ec54813aa4989f2c4028be333..3ce675b57f7b328e7c503d4f54ebdd41533c1163 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
@@ -7,6 +7,7 @@ import android.content.SharedPreferences
 import android.location.Location
 import android.location.LocationListener
 import android.location.LocationManager
+import android.net.ConnectivityManager
 import android.os.Bundle
 import android.util.Log
 import android.view.View
@@ -109,24 +110,39 @@ 		locationManager.removeUpdates(this)
 	}
 
 	private fun getItemsByQuery(server: Server, query: String) {
+		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val itemsStream = queryItems(server, query)
-			if (itemsStream == null) {
+			val itemsResult = queryItems(cm, server, query)
+			val response = if (itemsResult.stream != null) {
+				unmarshallItemResponse(itemsResult.stream)
+			} else {
+				null
+			}
+			if (itemsResult.error != null) {
+				Log.e("Results.query", "$itemsResult")
+				Log.e("Results.query", "$response")
 				// todo(error-handling) show empty state
 			} else {
-				updateItems(unmarshallItemResponse(itemsStream))
+				updateItems((response as ItemsSuccess).items)
 			}
 		}
 	}
 
 	private fun getItemsByLocation(server: Server, plusCode: String) {
-		Log.v("RESPONSE", "getting ItemsByLocation")
+		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val itemsStream = locateItems(server, plusCode)
-			if (itemsStream == null) {
+			val itemsResult = locateItems(cm, server, plusCode)
+			val response = if (itemsResult.stream != null) {
+				unmarshallItemResponse(itemsResult.stream)
+			} else {
+				null
+			}
+			if (itemsResult.error != null) {
+				Log.e("Results.location", "$itemsResult")
+				Log.e("Results.location", "$response")
 				// todo(error-handling) show empty state
 			} else {
-				updateItems(unmarshallItemResponse(itemsStream))
+				updateItems((response as ItemsSuccess).items)
 			}
 		}
 	}
@@ -137,17 +153,9 @@ 		binding.resultsRecycler.visibility = View.VISIBLE
 		adapter.update(items)
 	}
 
-	private suspend fun unmarshallItemResponse(stream: InputStream): List<Item> {
+	private suspend fun unmarshallItemResponse(stream: InputStream): ItemsResponse {
 		return withContext(Dispatchers.IO) {
-			when (val response = ItemsResponse.unmarshall(stream)) {
-				is ItemsSuccess -> {
-					return@withContext response.items
-				}
-				else -> {
-					TODO("Error response")
-				}
-			}
+			ItemsResponse.unmarshal(stream)
 		}
 	}
-
 }
\ 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 bda5ab02128c4d6d4bf6fbcbbcacb176f743d9ae..906554c8b1e3a54af2cfe5069b4641644dff11d8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -11,4 +11,13 @@ 	Continue
 	<string name="save">Save</string>
 	<string name="server_info_rate_limited">this server is rate limited;\nthe server admin can be contacted at\n%s\n\nit offers the following feeds:</string>
 	<string name="server_info_not_rate_limited">this server is not rate limited;\nthe server admin can be contacted at\n%s\n\nit offers the following feeds:</string>
+	<string name="error_400">The application made a malformed request</string>
+	<string name="error_401">A token is needed to use this server</string>
+	<string name="error_403">The token you provided is incorrect</string>
+	<string name="error_404">Not found</string>
+	<string name="error_429">Rate limit exceeded. Try again later</string>
+	<string name="error_50x">There was an error on the sever. Try again later</string>
+	<string name="error_unknown">Unknown error happened</string>
+	<string name="error_connecting">Error connecting to the server. Try again later</string>
+	<string name="error_offline">You are offline. Connect to the Internet</string> <!-- send a bug report to bimba@git.apiote.xyz, details are: url=$URL, response=$response -->
 </resources>
\ No newline at end of file