diff --git a/app/src/main/java/ro/code4/monitorizarevot/exceptions/ErrorCodes.kt b/app/src/main/java/ro/code4/monitorizarevot/exceptions/ErrorCodes.kt new file mode 100644 index 00000000..1df5dfb1 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/exceptions/ErrorCodes.kt @@ -0,0 +1,7 @@ +package ro.code4.monitorizarevot.exceptions + +object ErrorCodes { + const val UNAUTHORIZED = 401 + const val NOT_FOUND = 404 + const val BAD_REQUEST = 400 +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/exceptions/RetrofitException.kt b/app/src/main/java/ro/code4/monitorizarevot/exceptions/RetrofitException.kt new file mode 100644 index 00000000..4cf78f55 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/exceptions/RetrofitException.kt @@ -0,0 +1,106 @@ +package ro.code4.monitorizarevot.exceptions + +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Response +import retrofit2.Retrofit +import java.io.IOException + + +/** + * Exception that is retrieved from retrofit. It is of three types, http, network and unexpected. + */ +class RetrofitException internal constructor( + message: String?, + /** + * RobResponse object containing status code, headers, body, etc. + */ + val response: Response<*>?, + /** + * The event kind which triggered this error. + */ + val kind: Kind, + val exception: Throwable?, + /** + * The Retrofit this request was executed on + */ + val retrofit: Retrofit? +) : + RuntimeException(message, exception) { + /** + * Identifies the event kind which triggered a [RetrofitException]. + */ + enum class Kind { + /** + * An [IOException] occurred while communicating to the server. + */ + NETWORK, + + /** + * A non-200 HTTP status code was received from the server. + */ + HTTP, + + /** + * An internal error occurred while attempting to execute a request. It is best practice to + * re-throw this exception so your application crashes. + */ + UNEXPECTED + } + + /** + * HTTP response body converted to specified `type`. `null` if there is no + * response. + * + * @param type + * @throws IOException if unable to convert the body to the specified `type`. + */ + @Throws(IOException::class) + fun getErrorBodyAs(type: Class<*>?): T? { + if (response?.errorBody() == null) { + return null + } + val converter: Converter = + retrofit!!.responseBodyConverter(type, arrayOfNulls(0)) + return converter.convert(response.errorBody()) + } + + companion object { + fun httpError( + response: Response<*>, + retrofit: Retrofit? + ): RetrofitException { + val message = response.code().toString() + " " + response.message() + return RetrofitException( + message, + response, + Kind.HTTP, + null, + retrofit + ) + } + + fun networkError(exception: IOException): RetrofitException { + return RetrofitException( + exception.message, + null, + Kind.NETWORK, + exception, + null + ) + } + + fun unexpectedError(exception: Throwable): RetrofitException { + return RetrofitException( + exception.message, + null, + Kind.UNEXPECTED, + exception, + null + ) + } + } + +} + + diff --git a/app/src/main/java/ro/code4/monitorizarevot/extensions/RetrofitExtensions.kt b/app/src/main/java/ro/code4/monitorizarevot/extensions/RetrofitExtensions.kt new file mode 100644 index 00000000..3eaf02ad --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/extensions/RetrofitExtensions.kt @@ -0,0 +1,9 @@ +package ro.code4.monitorizarevot.extensions + +import retrofit2.HttpException +import retrofit2.Response + +fun Response.successOrThrow(): Boolean { + if (!isSuccessful) throw HttpException(this) + return true +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/extensions/RxErrorHandlingCallAdapterFactory.kt b/app/src/main/java/ro/code4/monitorizarevot/extensions/RxErrorHandlingCallAdapterFactory.kt new file mode 100644 index 00000000..44fa8fa1 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/extensions/RxErrorHandlingCallAdapterFactory.kt @@ -0,0 +1,117 @@ +package ro.code4.monitorizarevot.extensions + +import io.reactivex.* +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import ro.code4.monitorizarevot.exceptions.RetrofitException +import ro.code4.monitorizarevot.exceptions.RetrofitException.Companion.httpError +import ro.code4.monitorizarevot.exceptions.RetrofitException.Companion.networkError +import ro.code4.monitorizarevot.exceptions.RetrofitException.Companion.unexpectedError +import java.io.IOException +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * Rxjava error handling CallAdapter factory. This class ensures the mapping of errors to + * one of the following exceptions: http, network or unexpected exceptions. + */ +class RxErrorHandlingCallAdapterFactory private constructor() : CallAdapter.Factory() { + private val original: RxJava2CallAdapterFactory = RxJava2CallAdapterFactory.create() + + override fun get( + returnType: Type, annotations: Array, + retrofit: Retrofit + ): CallAdapter<*, *> { + return RxCallAdapterWrapper( + returnType, + retrofit, + (original.get(returnType, annotations, retrofit) as CallAdapter) + ) + } + + internal inner class RxCallAdapterWrapper( + private val returnType: Type, + private val retrofit: Retrofit, + private val wrapped: CallAdapter + ) : + CallAdapter { + override fun responseType(): Type { + return wrapped.responseType() + } + + override fun adapt(call: Call): Any? { + val rawType = getRawType(returnType) + + val isFlowable = rawType == Flowable::class.java + val isSingle = rawType == Single::class.java + val isMaybe = rawType == Maybe::class.java + val isCompletable = rawType == Completable::class.java + if (rawType != Observable::class.java && !isFlowable && !isSingle && !isMaybe) { + return null + } + if (returnType !is ParameterizedType) { + val name = if (isFlowable) + "Flowable" + else if (isSingle) "Single" else if (isMaybe) "Maybe" else "Observable" + throw IllegalStateException( + name + + " return type must be parameterized" + + " as " + + name + + " or " + + name + + "" + ) + } + + if (isFlowable) { + return (wrapped.adapt(call) as Flowable<*>).onErrorResumeNext { throwable: Throwable -> + Flowable.error(asRetrofitException(throwable)) + } + } + if (isSingle) { + return (wrapped.adapt(call) as Single<*>).onErrorResumeNext { throwable -> + Single.error(asRetrofitException(throwable)) + } + } + if (isMaybe) { + return (wrapped.adapt(call) as Maybe<*>).onErrorResumeNext { throwable: Throwable -> + Maybe.error(asRetrofitException(throwable)) + } + } + if (isCompletable) { + return (wrapped.adapt(call) as Completable).onErrorResumeNext { throwable -> + Completable.error(asRetrofitException(throwable)) + } + } + return (wrapped.adapt(call) as Observable<*>).onErrorResumeNext { throwable: Throwable -> + Observable.error(asRetrofitException(throwable)) + } + } + + private fun asRetrofitException(throwable: Throwable): RetrofitException { + return when (throwable) { + is HttpException -> { + val response = throwable.response() + httpError(response!!, retrofit) + } + is IOException -> { + networkError(throwable) + } + else -> unexpectedError(throwable) + } + } + } + + companion object { + const val TAG = "RxErrorHandlingCallAdapterFactory" + + fun create(): CallAdapter.Factory { + return RxErrorHandlingCallAdapterFactory() + } + } +} + diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/APIError400.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/APIError400.kt deleted file mode 100644 index ffa88f7d..00000000 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/APIError400.kt +++ /dev/null @@ -1,3 +0,0 @@ -package ro.code4.monitorizarevot.helper - -class APIError400 (var error: String) \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/ErrorResponse.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/ErrorResponse.kt new file mode 100644 index 00000000..8a1b717a --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/ErrorResponse.kt @@ -0,0 +1,5 @@ +package ro.code4.monitorizarevot.helper + +import com.google.gson.annotations.Expose + +data class ErrorResponse(@Expose var error: String) \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/Result.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/Result.kt index e5e5583d..ef6898c0 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/Result.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/Result.kt @@ -1,13 +1,21 @@ package ro.code4.monitorizarevot.helper + /** - * .:.:.:. Created by @henrikhorbovyi on 13/10/19 .:.:.:. + + * Class that encapsulates successful result with a value of type [T] or + * a failure with a [Throwable] exception. */ sealed class Result { - class Failure(val error: Throwable, val message: String = "") : Result() - class Success(val data: T? = null) : Result() + data class Error(val exception: Throwable) : Result() + data class Success(val data: T? = null) : Result() object Loading : Result() + fun exceptionOrNull(): Throwable? = + when (this) { + is Error -> exception + else -> null + } fun handle( onSuccess: (T?) -> Unit = {}, @@ -16,8 +24,32 @@ sealed class Result { ) { when (this) { is Success -> onSuccess(data) - is Failure -> onFailure(error) + is Error -> onFailure(exception) is Loading -> onLoading() } } -} \ No newline at end of file + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data]" + is Error -> "Error[exception=$exception]" + Loading -> "Loading" + } + } +} + +val Result<*>.succeeded + get() = this is Result.Success && data != null + +val Result<*>.error + get() = this is Result.Error + +inline fun Result.getOrThrow(onFailure: (exception: Throwable) -> R): R { + return when (val exception = exceptionOrNull()) { + null -> data as T + else -> onFailure(exception) + } +} + +val Result.data: T? + get() = (this as? Result.Success)?.data diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt index 24895fae..51ffeb24 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt @@ -3,6 +3,7 @@ package ro.code4.monitorizarevot.helper import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context +import android.content.DialogInterface import android.content.Intent import android.graphics.Rect import android.graphics.Typeface @@ -14,13 +15,17 @@ import android.os.Bundle import android.os.Environment import android.provider.MediaStore import android.text.* +import android.text.method.LinkMovementMethod import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import android.text.util.Linkify import android.view.MotionEvent import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.TextView import android.widget.EditText import androidx.annotation.IdRes +import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -45,6 +50,7 @@ import ro.code4.monitorizarevot.ui.section.PollingStationActivity import java.io.File import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.TimeUnit fun String.createMultipart(name: String): MultipartBody.Part { @@ -447,6 +453,36 @@ fun Context.browse(url: String, newTask: Boolean = false): Boolean { } } +fun Activity.createAndShowDialog( + message: String, + callback:() -> Unit, + title: String = getString(R.string.error_generic) +): AlertDialog? { + + val s = SpannableString(message) + + //added a TextView + val tx1 = TextView(this) + tx1.text = s + tx1.autoLinkMask = Activity.RESULT_OK + tx1.movementMethod = LinkMovementMethod.getInstance() + val valueInPixels = resources.getDimension(R.dimen.big_margin).toInt() + tx1.setPadding(valueInPixels, valueInPixels, valueInPixels, valueInPixels) + + Linkify.addLinks(s, Linkify.PHONE_NUMBERS) + val builder = AlertDialog.Builder(this) + return builder.setTitle(title) + .setCancelable(false) + .setPositiveButton(R.string.push_notification_ok) + { p0, _ -> p0.dismiss() } + .setCancelable(false) + .setOnDismissListener { + callback() + } + .setView(tx1) + .show() +} + @Suppress("NOTHING_TO_INLINE") internal inline fun FirebaseRemoteConfig?.getStringOrDefault(key: String, defaultValue: String) = this?.getString(key).takeUnless { diff --git a/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt b/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt index 8cf3f6f0..d5904236 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt @@ -19,6 +19,7 @@ import ro.code4.monitorizarevot.App import ro.code4.monitorizarevot.BuildConfig.API_URL import ro.code4.monitorizarevot.BuildConfig.DEBUG import ro.code4.monitorizarevot.data.AppDatabase +import ro.code4.monitorizarevot.extensions.RxErrorHandlingCallAdapterFactory import ro.code4.monitorizarevot.helper.getToken import ro.code4.monitorizarevot.repositories.Repository import ro.code4.monitorizarevot.ui.forms.FormsViewModel @@ -84,9 +85,9 @@ val apiModule = module { single { Retrofit.Builder() .baseUrl(API_URL) - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .addConverterFactory(ScalarsConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(gson)) + .addCallAdapterFactory(RxErrorHandlingCallAdapterFactory.create()) .client(get()) .build() } diff --git a/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt b/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt index 05cff46e..b463ec5d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt @@ -3,6 +3,7 @@ package ro.code4.monitorizarevot.repositories import android.annotation.SuppressLint import android.util.Log import androidx.lifecycle.LiveData +import io.reactivex.Completable import io.reactivex.Observable import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers @@ -14,6 +15,7 @@ import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.ResponseBody import org.koin.core.KoinComponent import org.koin.core.inject +import retrofit2.Response import retrofit2.Retrofit import ro.code4.monitorizarevot.data.AppDatabase import ro.code4.monitorizarevot.data.model.* @@ -25,7 +27,9 @@ import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.FormWithSections import ro.code4.monitorizarevot.data.pojo.PollingStationInfo import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions +import ro.code4.monitorizarevot.extensions.successOrThrow import ro.code4.monitorizarevot.helper.createMultipart +import ro.code4.monitorizarevot.helper.logD import ro.code4.monitorizarevot.services.ApiInterface import ro.code4.monitorizarevot.services.LoginInterface import java.io.File @@ -122,8 +126,12 @@ class Repository : KoinComponent { db.formDetailsDao().getSectionsWithQuestions(formId) fun getForms(): Observable { + val observableDb = db.formDetailsDao().getFormsWithSections() val observableApi = apiInterface.getForms() + + // todo fix this as it does not work as expected. nulls are not treated as return values. + // instead it throws a NullPointerException. return Observable.zip( observableDb.onErrorReturn { null }, observableApi.onErrorReturn { null }, @@ -180,7 +188,8 @@ class Repository : KoinComponent { apiFormDetails.forEach { apiForm -> val dbForm = dbFormDetails.find { it.form.id == apiForm.id } if (dbForm != null && (apiForm.formVersion != dbForm.form.formVersion || - apiForm.order != dbForm.form.order)) { + apiForm.order != dbForm.form.order) + ) { deleteFormDetails(dbForm.form) saveFormDetails(apiForm) } @@ -236,25 +245,31 @@ class Repository : KoinComponent { }) } - @SuppressLint("CheckResult") - fun syncAnswers(countyCode: String, pollingStationNumber: Int, formId: Int) { - db.formDetailsDao().getNotSyncedQuestionsForForm(countyCode, pollingStationNumber, formId) + fun syncAnswers( + countyCode: String, + pollingStationNumber: Int, + formId: Int + ): Observable { + return db.formDetailsDao() + .getNotSyncedQuestionsForForm(countyCode, pollingStationNumber, formId) .toObservable() - .subscribeOn(Schedulers.io()).flatMap { + .subscribeOn(Schedulers.io()) + .flatMap { syncAnswers(it) - }.observeOn(AndroidSchedulers.mainThread()).subscribe({ - Observable.create { + } + .map { it.successOrThrow() } + .flatMap { + Completable.fromAction { + logD("saving the forms details to db for stationNr:$pollingStationNumber", TAG) db.formDetailsDao() .updateAnsweredQuestions(countyCode, pollingStationNumber, formId) - }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) - .subscribe() - }, { - Log.i(TAG, it.message ?: "Error on synchronizing data") - }) + + }.andThen(Observable.just(it)) + } } - private fun syncAnswers(list: List): Observable { + private fun syncAnswers(list: List): Observable> { val responseAnswerContainer = ResponseAnswerContainer() responseAnswerContainer.answers = list.map { it.answeredQuestion.options = it.selectedAnswers diff --git a/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt b/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt index 21d36782..2c28252a 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt @@ -4,6 +4,7 @@ import io.reactivex.Observable import io.reactivex.Single import okhttp3.MultipartBody import okhttp3.ResponseBody +import retrofit2.Response import retrofit2.http.* import ro.code4.monitorizarevot.data.model.County import ro.code4.monitorizarevot.data.model.PollingStation @@ -25,7 +26,7 @@ interface ApiInterface { fun postPollingStationDetails(@Body pollingStation: PollingStation): Observable @POST("/api/v1/answers") - fun postQuestionAnswer(@Body responseAnswer: ResponseAnswerContainer): Observable + fun postQuestionAnswer(@Body responseAnswer: ResponseAnswerContainer): Observable> @Multipart @POST("/api/v2/note/upload") diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt index 1ff0b752..9941c571 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt @@ -1,35 +1,25 @@ package ro.code4.monitorizarevot.ui.base -import android.app.Activity import android.content.Context import android.os.Bundle -import android.text.SpannableString -import android.text.method.LinkMovementMethod -import android.text.util.Linkify import android.view.MotionEvent import android.view.View -import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar -import com.google.gson.Gson -import retrofit2.HttpException import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.helper.APIError400 import ro.code4.monitorizarevot.helper.LocaleManager import ro.code4.monitorizarevot.helper.collapseKeyboardIfFocusOutsideEditText -import ro.code4.monitorizarevot.helper.fromJson import ro.code4.monitorizarevot.helper.lifecycle.ActivityCallbacks import ro.code4.monitorizarevot.interfaces.Layout import ro.code4.monitorizarevot.interfaces.ViewModelSetter - abstract class BaseActivity : AppCompatActivity(), Layout, ViewModelSetter { private val mCallbacks = ActivityCallbacks() - private var dialog: AlertDialog? = null + internal var dialog: AlertDialog? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) LocaleManager.wrapContext(this) @@ -74,51 +64,6 @@ abstract class BaseActivity : AppCompatActivity(), Layout } } - fun handleThrowable(exception: Throwable, otherError: () -> Unit) { - when (exception) { - is HttpException -> { - if (exception.code() == 400) { - val apiError400 = exception.response()?.errorBody()?.string()?.fromJson(Gson(), APIError400::class.java) - - apiError400?.let { - if (it.error == null) { - otherError() - - return - } - - val s = SpannableString(it.error) - - //added a TextView - val tx1 = TextView(this) - tx1.text = s - tx1.autoLinkMask = Activity.RESULT_OK - tx1.movementMethod = LinkMovementMethod.getInstance() - val valueInPixels = resources.getDimension(R.dimen.big_margin).toInt() - tx1.setPadding(valueInPixels, valueInPixels, valueInPixels, valueInPixels) - - Linkify.addLinks(s, Linkify.PHONE_NUMBERS) - val builder = AlertDialog.Builder(this) - builder.setTitle(getString(R.string.error_generic)) - .setCancelable(false) - .setPositiveButton(R.string.push_notification_ok) - { p0, _ -> p0.dismiss() } - .setCancelable(false) - .setOnDismissListener { dialog = null } - .setView(tx1) - .show() - - } - } else { - otherError() - } - } - else -> { - otherError() - } - } - } - fun showDefaultErrorSnackBar(view: View) { showErrorSnackBar(view, getString(R.string.error_generic)) } diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseViewModelFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseViewModelFragment.kt new file mode 100644 index 00000000..494099d6 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseViewModelFragment.kt @@ -0,0 +1,51 @@ +package ro.code4.monitorizarevot.ui.base + +import ro.code4.monitorizarevot.exceptions.ErrorCodes +import ro.code4.monitorizarevot.exceptions.RetrofitException +import ro.code4.monitorizarevot.helper.logE +import ro.code4.monitorizarevot.helper.startActivityWithoutTrace +import ro.code4.monitorizarevot.ui.login.LoginActivity + +abstract class BaseViewModelFragment : ViewModelFragment() { + + override fun onError(thr: Throwable) { + when (thr) { + is RetrofitException -> { + processRetrofitException(thr) + } + } + } + + protected open fun processRetrofitException(thr: RetrofitException) { + when (thr.kind) { + RetrofitException.Kind.HTTP -> { + when (thr.response?.code()) { + ErrorCodes.UNAUTHORIZED -> { + startLoginActivity() + } + ErrorCodes.BAD_REQUEST -> { + logE(TAG, "unknown error.") + } + else -> { + logE(TAG, "unexpected exception.") + } + } + } + + RetrofitException.Kind.NETWORK -> { + logE(TAG, "network error.") + } + + RetrofitException.Kind.UNEXPECTED -> { + logE(TAG, "unexpected error.") + } + } + } + + private fun startLoginActivity() = + activity?.startActivityWithoutTrace(activity = LoginActivity::class.java) + + companion object { + const val TAG = "BaseViewModelFragment" + } +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/base/ViewModelFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/base/ViewModelFragment.kt index c93d8310..2ee27dea 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/base/ViewModelFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/base/ViewModelFragment.kt @@ -16,6 +16,7 @@ abstract class ViewModelFragment : BaseAnalyticsFragment( super.onAttach(context) mContext = context } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -23,4 +24,6 @@ abstract class ViewModelFragment : BaseAnalyticsFragment( ): View? { return inflater.inflate(layout, container, false) } + + open fun onError(thr: Throwable) = Unit } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt index 26d92b1f..8d15f735 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt @@ -12,7 +12,9 @@ import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.helper.Constants.FORM import ro.code4.monitorizarevot.helper.Constants.QUESTION import ro.code4.monitorizarevot.helper.changePollingStation +import ro.code4.monitorizarevot.helper.data import ro.code4.monitorizarevot.helper.replaceFragment +import ro.code4.monitorizarevot.helper.succeeded import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.ui.forms.questions.QuestionsDetailsFragment import ro.code4.monitorizarevot.ui.forms.questions.QuestionsListFragment @@ -37,16 +39,16 @@ class FormsFragment : ViewModelFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.pollingStation().observe(this, Observer { + viewModel.pollingStation().observe(viewLifecycleOwner, Observer { pollingStationBarText.text = getString(R.string.polling_station, it.pollingStationNumber, it.countyName) }) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { (activity as MainActivity).setTitle(it) }) - viewModel.selectedForm().observe(this, Observer { + viewModel.selectedForm().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, QuestionsListFragment(), @@ -54,7 +56,7 @@ class FormsFragment : ViewModelFragment() { QuestionsListFragment.TAG ) }) - viewModel.selectedQuestion().observe(this, Observer { + viewModel.selectedQuestion().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, QuestionsDetailsFragment(), @@ -65,7 +67,7 @@ class FormsFragment : ViewModelFragment() { QuestionsDetailsFragment.TAG ) }) - viewModel.navigateToNotes().observe(this, Observer { + viewModel.navigateToNotes().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, NoteFragment(), @@ -86,6 +88,4 @@ class FormsFragment : ViewModelFragment() { ) } - - } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt index c4a0efb9..a281487c 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt @@ -41,15 +41,15 @@ class FormsListFragment : ViewModelFragment() { override fun onAttach(context: Context) { super.onAttach(context) - viewModel = getSharedViewModel(from = { parentFragment!! }) + viewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.forms().observe(this, Observer { + viewModel.forms().observe(viewLifecycleOwner, Observer { formAdapter.items = it }) - viewModel.syncVisibility().observe(this, Observer { + viewModel.syncVisibility().observe(viewLifecycleOwner, Observer { syncGroup.visibility = it }) diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt index d057a686..21ea2dfd 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt @@ -10,12 +10,17 @@ import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.data.model.FormDetails import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions +import ro.code4.monitorizarevot.helper.Result import ro.code4.monitorizarevot.ui.base.BaseFormViewModel abstract class BaseQuestionViewModel : BaseFormViewModel() { val questionsLiveData = MutableLiveData>() var selectedFormId: Int = -1 + + val syncLiveData = MutableLiveData>() fun questions(): LiveData> = questionsLiveData + fun syncData(): LiveData> = syncLiveData + private fun getQuestions(formId: Int) { selectedFormId = formId diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsFragment.kt index 1c52d54f..cb077d26 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsFragment.kt @@ -3,6 +3,7 @@ package ro.code4.monitorizarevot.ui.forms.questions import android.content.Context import android.os.Bundle import android.os.Parcelable +import android.util.Log import android.view.View import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager @@ -18,14 +19,12 @@ import ro.code4.monitorizarevot.adapters.QuestionDetailsAdapter import ro.code4.monitorizarevot.adapters.helper.QuestionDetailsListItem import ro.code4.monitorizarevot.data.model.FormDetails import ro.code4.monitorizarevot.data.model.Question -import ro.code4.monitorizarevot.helper.Constants -import ro.code4.monitorizarevot.helper.addOnLayoutChangeListenerForGalleryEffect -import ro.code4.monitorizarevot.helper.addOnScrollListenerForGalleryEffect -import ro.code4.monitorizarevot.ui.base.ViewModelFragment +import ro.code4.monitorizarevot.helper.* +import ro.code4.monitorizarevot.ui.base.BaseViewModelFragment import ro.code4.monitorizarevot.ui.forms.FormsViewModel -class QuestionsDetailsFragment : ViewModelFragment(), +class QuestionsDetailsFragment : BaseViewModelFragment(), QuestionDetailsAdapter.OnClickListener { override fun addNoteFor(question: Question) { baseViewModel.selectedNotes(question) @@ -49,17 +48,17 @@ class QuestionsDetailsFragment : ViewModelFragment(), override fun onAttach(context: Context) { super.onAttach(context) - baseViewModel = getSharedViewModel(from = { parentFragment!! }) + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.questions().observe(this, Observer { list -> + viewModel.questions().observe(viewLifecycleOwner, Observer { list -> setData(ArrayList(list.map { it as QuestionDetailsListItem })) }) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { baseViewModel.setTitle(it) }) @@ -107,9 +106,15 @@ class QuestionsDetailsFragment : ViewModelFragment(), setButtons() } } - }) + viewModel.syncData().observe(viewLifecycleOwner, Observer { it -> + val result = it.getOrThrow { + logE(TAG, "exception when syncing data:" + it.message) + onError(it) + } + logD(TAG, "success when syncing the data:$result") + }) } private fun setButtons() { @@ -159,8 +164,7 @@ class QuestionsDetailsFragment : ViewModelFragment(), if (::adapter.isInitialized) { viewModel.saveAnswer(adapter.getItem(currentPosition)) } - viewModel.syncData() + viewModel.syncAnswersData() super.onPause() - } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt index a3b226c1..942df25a 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt @@ -1,5 +1,7 @@ package ro.code4.monitorizarevot.ui.forms.questions +import android.annotation.SuppressLint +import io.reactivex.android.schedulers.AndroidSchedulers import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.adapters.helper.MultiChoiceListItem import ro.code4.monitorizarevot.adapters.helper.QuestionDetailsListItem @@ -12,6 +14,9 @@ import ro.code4.monitorizarevot.helper.Constants.TYPE_MULTI_CHOICE import ro.code4.monitorizarevot.helper.Constants.TYPE_MULTI_CHOICE_DETAILS import ro.code4.monitorizarevot.helper.Constants.TYPE_SINGLE_CHOICE import ro.code4.monitorizarevot.helper.Constants.TYPE_SINGLE_CHOICE_DETAILS +import ro.code4.monitorizarevot.helper.Result +import ro.code4.monitorizarevot.helper.logD +import ro.code4.monitorizarevot.helper.logE class QuestionsDetailsViewModel : BaseQuestionViewModel() { @@ -82,8 +87,24 @@ class QuestionsDetailsViewModel : BaseQuestionViewModel() { } } - fun syncData() { - repository.syncAnswers(countyCode, pollingStationNumber, selectedFormId) + @SuppressLint("CheckResult") + fun syncAnswersData() { + // todo a better solution is required for disposing this. + // if added to the CompositeDisposable it will be disposed before finishing. + repository.syncAnswers(countyCode, pollingStationNumber, selectedFormId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { + logD("response success:$it", TAG) + syncLiveData.postValue(Result.Success(it)) + }, { + logE("response error:$it", TAG) + syncLiveData.postValue(Result.Error(it)) + } + ) } + companion object { + const val TAG = "QuestionsDetailsViewModel" + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt index 2d0ca824..77d64c2d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt @@ -37,16 +37,16 @@ class QuestionsListFragment : ViewModelFragment() { override fun onAttach(context: Context) { super.onAttach(context) - baseViewModel = getSharedViewModel(from = { parentFragment!! }) + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.questions().observe(this, Observer { + viewModel.questions().observe(viewLifecycleOwner, Observer { questionAdapter.items = it }) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { baseViewModel.setTitle(it) }) viewModel.setData(Parcels.unwrap(arguments?.getParcelable((FORM)))) @@ -62,5 +62,4 @@ class QuestionsListFragment : ViewModelFragment() { } } - } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginActivity.kt index 650e56dc..2d03ff2d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginActivity.kt @@ -8,9 +8,9 @@ import kotlinx.android.synthetic.main.activity_login.* import org.koin.android.viewmodel.ext.android.viewModel import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.helper.TextWatcherDelegate -import ro.code4.monitorizarevot.helper.isOnline -import ro.code4.monitorizarevot.helper.startActivityWithoutTrace +import ro.code4.monitorizarevot.exceptions.ErrorCodes +import ro.code4.monitorizarevot.exceptions.RetrofitException +import ro.code4.monitorizarevot.helper.* import ro.code4.monitorizarevot.ui.base.BaseAnalyticsActivity import ro.code4.monitorizarevot.widget.ProgressDialogFragment @@ -60,13 +60,6 @@ class LoginActivity : BaseAnalyticsActivity() { private fun clickListenersSetup() { loginButton.setOnClickListener { - if (!isOnline()) { - Snackbar.make(loginButton, getString(R.string.login_no_internet), Snackbar.LENGTH_SHORT) - .show() - - return@setOnClickListener - } - loginButton.isEnabled = false viewModel.login(phone.text.toString(), password.text.toString()) } @@ -79,7 +72,7 @@ class LoginActivity : BaseAnalyticsActivity() { progressDialog.dismiss() activity?.let(::startActivityWithoutTrace) }, - onFailure = {error -> + onFailure = { error -> // TODO: Handle errors to show personalized messages for each one progressDialog.dismiss() @@ -95,4 +88,57 @@ class LoginActivity : BaseAnalyticsActivity() { ) }) } + + private fun handleThrowable(exception: Throwable, fallback: (exception: Throwable) -> Unit) { + when (exception) { + is RetrofitException -> { + when (exception.kind) { + RetrofitException.Kind.HTTP -> { + processHttpException(exception, fallback) + } + RetrofitException.Kind.NETWORK -> { + val messageId = + if (!isOnline()) R.string.login_no_internet else R.string.error_generic + + Snackbar.make( + loginButton, + messageId, + Snackbar.LENGTH_SHORT + ).show() + } + else -> { + fallback(exception) + } + } + } + } + } + + private fun processHttpException( + exception: RetrofitException, + fallback: (exception: Throwable) -> Unit + ) { + val message = exception.getErrorBodyAs(ErrorResponse::class.java)?.error ?: getString(R.string.error_generic) + when (exception.response?.code()) { + ErrorCodes.BAD_REQUEST -> { + createAndShowDialog( + message, { + dialog = null + }, + getString(R.string.login_bad_request) + ) + } + ErrorCodes.UNAUTHORIZED -> { + createAndShowDialog( + message, { + dialog = null + }, + getString(R.string.login_unauthorized) + ) + } + else -> { + fallback(exception) + } + } + } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt index e1868d3c..b4dc376d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt @@ -83,6 +83,6 @@ class LoginViewModel : BaseViewModel() { override fun onError(throwable: Throwable) { logE("onError ${throwable.message}", throwable) - loginLiveData.postValue(Result.Failure(throwable)) + loginLiveData.postValue(Result.Error(throwable)) } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt index 3ea9690a..b6277d0c 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt @@ -33,7 +33,6 @@ import ro.code4.monitorizarevot.helper.* import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_GALLERY import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_RECORD_VIDEO import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_TAKE_PHOTO -import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.ui.forms.FormsViewModel @@ -53,8 +52,8 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi private lateinit var permissionManager: PermissionManager override fun onAttach(context: Context) { super.onAttach(context) - permissionManager = PermissionManager(activity!!, this) - baseViewModel = getSharedViewModel(from = { parentFragment!! }) + permissionManager = PermissionManager(requireActivity(), this) + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -66,15 +65,15 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi .color(Color.TRANSPARENT) .sizeResId(R.dimen.small_margin).build() ) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { baseViewModel.setTitle(it) }) viewModel.setData(Parcels.unwrap(arguments?.getParcelable((Constants.QUESTION)))) - viewModel.notes().observe(this, Observer { + viewModel.notes().observe(viewLifecycleOwner, Observer { noteAdapter.items = it }) - viewModel.fileName().observe(this, Observer { + viewModel.fileName().observe(viewLifecycleOwner, Observer { filenameText.text = it filenameText.visibility = View.VISIBLE addMediaButton.visibility = View.GONE diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt index 931af7a3..1fab471b 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt @@ -38,6 +38,4 @@ class PollingStationActivity : BaseActivity() { }) replaceFragment(R.id.container, PollingStationSelectionFragment()) } - - } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt index 5040f525..9397c23f 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt @@ -36,7 +36,7 @@ class PollingStationDetailsFragment : ViewModelFragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.pollingStation().observe(this, Observer { + viewModel.pollingStation().observe(viewLifecycleOwner, Observer { pollingStationBarText.text = it }) viewModel.setTitle(getString(R.string.title_polling_station)) @@ -45,10 +45,10 @@ class PollingStationDetailsFragment : ViewModelFragment viewModel.notifyChangeRequested() activity?.onBackPressed() } - viewModel.departureTime().observe(this, Observer { + viewModel.departureTime().observe(viewLifecycleOwner, Observer { departureTime.text = it }) - viewModel.arrivalTime().observe(this, Observer { + viewModel.arrivalTime().observe(viewLifecycleOwner, Observer { arrivalTime.text = it }) arrivalTime.setOnClickListener { @@ -83,7 +83,7 @@ class PollingStationDetailsFragment : ViewModelFragment } }) } - viewModel.selectedPollingStation().observe(this, Observer { + viewModel.selectedPollingStation().observe(viewLifecycleOwner, Observer { setSelection(it) }) setContinueButton() @@ -100,7 +100,7 @@ class PollingStationDetailsFragment : ViewModelFragment private fun showDatePicker(dateTitleId: Int, timeTitleId: Int, listener: DateTimeListener) { val now = Calendar.getInstance() val datePickerDialog = DatePickerDialog( - activity!!, + requireActivity(), DatePickerDialog.OnDateSetListener { _, year, month, day -> showTimePicker(timeTitleId, year, month, day, listener) }, now.get(Calendar.YEAR), now.get(Calendar.MONTH), now.get(Calendar.DAY_OF_MONTH) diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt index 9c72bb3d..46ef187f 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt @@ -10,7 +10,6 @@ import kotlinx.android.synthetic.main.fragment_polling_station_selection.* import org.koin.android.viewmodel.ext.android.getSharedViewModel import org.koin.android.viewmodel.ext.android.viewModel import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.ui.section.PollingStationViewModel import ro.code4.monitorizarevot.widget.ProgressDialogFragment @@ -47,8 +46,10 @@ class PollingStationSelectionFragment : ViewModelFragmentEroare de server Eroare necunoscută Eroare permisiune %1$S + Eroare de conexiune + + + Nu a fost gasit niciun judet. Te rog conecteaza-te la retea si incearca din nou. v%1$S aplicație dezvoltată de @@ -20,6 +24,12 @@ Login Ai nevoie de o conexiune la internet pentru a te conecta! + + Bad Request! + + + Nu esti autorizat! + Continuă Înapoi @@ -141,6 +151,8 @@ A apărut o problemă! + + A aparut o problema si nu putem incarca datele! Despre aplicație @@ -150,4 +162,4 @@ Politică de confidențialitate Trimiteți email prin v%1$S build %2$d aplicație dezvoltată de - \ No newline at end of file + \ 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 3b39e68b..5f78a1d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,6 +12,10 @@ A server error has occurred An unknown error has occurred Permission error %1$S + Connection error + + + No counties found. Please connect to the network and try again. v%1$S app developed by @@ -19,6 +23,10 @@ Code Login You need to be connected to the internet to be able to log in! + Bad Request! + + + You are not authorized! Next @@ -147,6 +155,9 @@ Something went wrong! + + Something went wrong and we cannot retrieve data! + About this app Vote Monitor is an application created by Code for Romania.

This is the first app, developed in Romania, intended to be used as part of the vote monitoring process. It\'s also one of the few such apps in the world. The app has been used, by observers, ever since the prezidential elections in 2016. In Poland, it was used in 2018, in the first independently-monitored elections.

The app is open source. Check it out on GitHub.

Code for Romania is an NGO that develops IT applications, pro-bono, with the overall goal of solving the pain points identified in society. If you want to support our activity, head over to our website and join the community, as a volunteer, or donate.]]>