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