diff --git a/Project/.idea/compiler.xml b/Project/.idea/compiler.xml new file mode 100644 index 0000000..61a9130 --- /dev/null +++ b/Project/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Project/.idea/misc.xml b/Project/.idea/misc.xml index f5c6d9e..1493222 100644 --- a/Project/.idea/misc.xml +++ b/Project/.idea/misc.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/Project/app/src/main/java/org/ionproject/android/common/IonApplication.kt b/Project/app/src/main/java/org/ionproject/android/common/IonApplication.kt index 4dbbfe5..9630d1b 100644 --- a/Project/app/src/main/java/org/ionproject/android/common/IonApplication.kt +++ b/Project/app/src/main/java/org/ionproject/android/common/IonApplication.kt @@ -1,6 +1,8 @@ package org.ionproject.android.common import android.app.Application +import android.content.Context +import android.content.SharedPreferences import androidx.room.Room import org.ionproject.android.common.connectivity.ConnectivityObservableFactory import org.ionproject.android.common.connectivity.IConnectivityObservable @@ -8,6 +10,7 @@ import org.ionproject.android.common.db.AppDatabase import org.ionproject.android.common.ionwebapi.* import org.ionproject.android.common.repositories.* import org.ionproject.android.common.workers.WorkerManagerFacade +import org.ionproject.android.loading.RemoteConfigRepository import org.ionproject.android.settings.Preferences import retrofit2.Retrofit import retrofit2.converter.scalars.ScalarsConverterFactory @@ -37,6 +40,7 @@ class IonApplication : Application() { lateinit var globalExceptionHandler: GlobalExceptionHandler private set lateinit var preferences: Preferences private set lateinit var connectivityObservable: IConnectivityObservable private set + lateinit var remoteConfigRepository: RemoteConfigRepository private set } override fun onCreate() { @@ -44,6 +48,8 @@ class IonApplication : Application() { globalExceptionHandler = GlobalExceptionHandler() + preferences = Preferences(applicationContext) + /** * Our app runs in a single process therefore we follow * the singleton design pattern when instantiating an @@ -62,7 +68,7 @@ class IonApplication : Application() { //------- Using real API ------------- val retrofit = Retrofit.Builder() - .baseUrl(WEB_API_HOST) + .baseUrl(preferences.getWebApiHost()?: WEB_API_HOST) .addConverterFactory(ScalarsConverterFactory.create()) .build() @@ -103,9 +109,8 @@ class IonApplication : Application() { EventsRepository(db.eventsDao(), webAPI, workerManagerFacade) rootRepository = RootRepository(db.rootDao(), ionWebAPI, workerManagerFacade) searchRepository = SearchRepository(webAPI) - preferences = - Preferences(applicationContext) connectivityObservable = ConnectivityObservableFactory.create(applicationContext) - } + remoteConfigRepository = RemoteConfigRepository(preferences, ionMapper) + } } diff --git a/Project/app/src/main/java/org/ionproject/android/common/ionwebapi/IonService.kt b/Project/app/src/main/java/org/ionproject/android/common/ionwebapi/IonService.kt index 9e313bb..e9189ff 100644 --- a/Project/app/src/main/java/org/ionproject/android/common/ionwebapi/IonService.kt +++ b/Project/app/src/main/java/org/ionproject/android/common/ionwebapi/IonService.kt @@ -1,17 +1,12 @@ package org.ionproject.android.common.ionwebapi -import retrofit2.http.GET -import retrofit2.http.Header -import retrofit2.http.Headers -import retrofit2.http.Url +import retrofit2.http.* interface IonService { - @Headers("Authorization: Bearer $WEB_API_AUTHORIZATION_TOKEN") @GET suspend fun getFromUri( @Url uri: String, @Header("Accept") accept: String ): String - } diff --git a/Project/app/src/main/java/org/ionproject/android/common/repositories/RootRepository.kt b/Project/app/src/main/java/org/ionproject/android/common/repositories/RootRepository.kt index 276bd73..ebc2e4f 100644 --- a/Project/app/src/main/java/org/ionproject/android/common/repositories/RootRepository.kt +++ b/Project/app/src/main/java/org/ionproject/android/common/repositories/RootRepository.kt @@ -12,9 +12,6 @@ import org.ionproject.android.common.workers.WorkerManagerFacade import java.net.URI -// This uri has to be hardcoded there is no other way -private val ROOT_URI_V0 = URI("/") - /** * Used to check the existence of i-on core Web API root endpoints. It does so by * checking the Root resource. @@ -26,14 +23,14 @@ class RootRepository( private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - suspend fun getJsonHome() = + suspend fun getJsonHome(uri: URI) = withContext(dispatcher) { // Verify if root resource is stored locally var rootResource = rootResourceDao.getRootResource() if (rootResource == null) { rootResource = - ionWebAPI.getFromURI(ROOT_URI_V0, JsonHome::class.java, JSON_HOME_MEDIA_TYPE) + ionWebAPI.getFromURI(uri, JsonHome::class.java, JSON_HOME_MEDIA_TYPE) .toRoot() if (rootResource != null) { // Save root resource into local database and create a worker for it @@ -50,4 +47,5 @@ class RootRepository( } rootResource } + } \ No newline at end of file diff --git a/Project/app/src/main/java/org/ionproject/android/error/ErrorActivity.kt b/Project/app/src/main/java/org/ionproject/android/error/ErrorActivity.kt index 474a8f6..75499d2 100644 --- a/Project/app/src/main/java/org/ionproject/android/error/ErrorActivity.kt +++ b/Project/app/src/main/java/org/ionproject/android/error/ErrorActivity.kt @@ -1,5 +1,6 @@ package org.ionproject.android.error + import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import kotlinx.android.synthetic.main.activity_error.* @@ -24,6 +25,5 @@ class ErrorActivity : AppCompatActivity() { intent.getStringExtra(ERROR_ACTIVITY_EXCEPTION_EXTRA)?.apply { textview_error_activity_message.text = this } - } } diff --git a/Project/app/src/main/java/org/ionproject/android/loading/LoadingActivity.kt b/Project/app/src/main/java/org/ionproject/android/loading/LoadingActivity.kt index 0808782..7865230 100644 --- a/Project/app/src/main/java/org/ionproject/android/loading/LoadingActivity.kt +++ b/Project/app/src/main/java/org/ionproject/android/loading/LoadingActivity.kt @@ -1,16 +1,28 @@ package org.ionproject.android.loading import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.widget.Toast import androidx.lifecycle.ViewModelProvider import kotlinx.android.synthetic.main.activity_loading.* import org.ionproject.android.ExceptionHandlingActivity import org.ionproject.android.R +import org.ionproject.android.common.IonApplication import org.ionproject.android.common.addGradientBackground +import org.ionproject.android.common.model.Root +import org.ionproject.android.error.ERROR_KEY import org.ionproject.android.error.ErrorActivity import org.ionproject.android.main.MAIN_ACTIVITY_ROOT_EXTRA import org.ionproject.android.main.MainActivity +import java.net.URI +/** + * 1) Try and get data from the JsonHome URL + * 2) if it fails, get the URL from the Remote Config file + * 3) it it fails to get that info, check connectivity: open isel's page if it exists, redirect to no connect + * error if it doesn't + */ class LoadingActivity : ExceptionHandlingActivity() { private val loadingViewModel by lazy(LazyThreadSafetyMode.NONE) { @@ -22,16 +34,66 @@ class LoadingActivity : ExceptionHandlingActivity() { setContentView(R.layout.activity_loading) linearlayout_activity_loading.addGradientBackground() - // If JsonHome contains all required resources then open main activity else app must be outdated + /** + * If JsonHome contains all required resources then open main activity; if not app must be outdated + */ loadingViewModel.observeRootLiveData(this) { - if (it != null) { - val intent = Intent(this, MainActivity::class.java) - intent.putExtra(MAIN_ACTIVITY_ROOT_EXTRA, it) - this.startActivity(intent) - } else { - val intent = Intent(this, ErrorActivity::class.java) - this.startActivity(intent) + when (it) { + is FetchSuccess -> { + val intent = Intent(this, MainActivity::class.java) + intent.putExtra(MAIN_ACTIVITY_ROOT_EXTRA, it.value) + this.startActivity(intent) + } + is FetchFailure -> { + if (loadingViewModel.getRemoteConfigLiveData() == null) { + loadingViewModel.getRemoteConfig() + } else { + Toast.makeText( + this, + resources.getString(R.string.ion_api_down), + Toast.LENGTH_LONG + ).show() + openISELPage() + } + } } } + + /** + * We check the connectivity in this observer because, although unlikely, GitHub might + * be down and there needs to be a plan for that + */ + loadingViewModel.observeRemoteConfigLiveData(this) { + when (it) { + is FetchSuccess -> loadingViewModel.getJsonHome(URI(it.value.api_link)) + is FetchFailure -> { + if (!IonApplication.connectivityObservable.hasConnectivity()) { + startActivity( + Intent(this, ErrorActivity::class.java) + .putExtra( + ERROR_KEY, + resources.getString(R.string.label_no_connectivity_loading_error) + ) + ) + } else { + Toast.makeText( + this, + resources.getString(R.string.remote_config_unreachable), + Toast.LENGTH_LONG + ).show() + openISELPage() + } + } + } + } + } + + /** + * if the remote config link is the same as the link in the app, + it means the API is down and for now we redirect to the ISEL website + */ + private fun openISELPage() { + val iselURL = "https://www.isel.pt" + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(iselURL))) } } diff --git a/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModel.kt b/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModel.kt index 45030cf..815e423 100644 --- a/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModel.kt +++ b/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModel.kt @@ -4,19 +4,79 @@ import androidx.lifecycle.* import kotlinx.coroutines.launch import org.ionproject.android.common.model.Root import org.ionproject.android.common.repositories.RootRepository +import java.net.URI -class LoadingViewModel(private val rootRepository: RootRepository) : ViewModel() { +// This uri has to be hardcoded there is no other way +private val ROOT_URI_V0 = URI("/") - private val rootLiveData = MutableLiveData() +/** +getJsonHome() returns null when the API is unavailable. This makes the default null status of +rootLiveData ambiguous, so this class envelops the results of the getJsonHome() method in order to +properly evaluate the state of the API and to launch the Remote Config strat + +Fetch Failure with null value: request was made to API and there was no response +Fetch Failure with throwable: request threw an Exception, if not caught triggers the global Exception Handler +from ExceptionHandlingActivity() + +Fetch Success : valid response + */ +sealed class FetchResult +data class FetchFailure(val throwable: Throwable? = null) : FetchResult() +data class FetchSuccess(val value: T) : FetchResult() + +class LoadingViewModel( + private val rootRepository: RootRepository, + private val remoteConfigRepository: RemoteConfigRepository +) : ViewModel() { + + private val rootLiveData = MutableLiveData>() + private val remoteConfigLiveData = MutableLiveData>() init { + getJsonHome(ROOT_URI_V0) + } + + /** + Retrofit note: since retrofit 2 uses okhttp's HttpUrl, we don't need to change the base url of + the service in endpoints where the method annotation doesn't specify a url. it detects if the + @URL parameter doesn't match with the base url and sorts everything out by itself + */ + fun getJsonHome(uri: URI) { viewModelScope.launch { - val root = rootRepository.getJsonHome() - rootLiveData.postValue(root) + val result = try { + val root = rootRepository.getJsonHome(uri) + if (root != null) FetchSuccess(root) else FetchFailure() + } catch (e: Exception) { + FetchFailure(e) + } + + rootLiveData.postValue(result) } } - fun observeRootLiveData(lifecycleOwner: LifecycleOwner, onUpdate: (Root?) -> Unit) { + fun getRemoteConfig() { + viewModelScope.launch { + val result = try { + val remoteConfig = remoteConfigRepository.getRemoteConfig() + if (remoteConfig?.api_link != null) FetchSuccess(remoteConfig) else FetchFailure() + } catch (e: Exception) { + FetchFailure(e) + } + + remoteConfigLiveData.postValue(result) + } + } + + fun observeRootLiveData(lifecycleOwner: LifecycleOwner, onUpdate: (FetchResult) -> Unit) { rootLiveData.observe(lifecycleOwner, Observer { onUpdate(it) }) } + + fun observeRemoteConfigLiveData( + lifecycleOwner: LifecycleOwner, + onUpdate: (FetchResult) -> Unit + ) { + remoteConfigLiveData.observe(lifecycleOwner, Observer { onUpdate(it) }) + } + + fun getRemoteConfigLiveData() = remoteConfigLiveData.value } \ No newline at end of file diff --git a/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModelProvider.kt b/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModelProvider.kt index 5c02ffd..070920d 100644 --- a/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModelProvider.kt +++ b/Project/app/src/main/java/org/ionproject/android/loading/LoadingViewModelProvider.kt @@ -9,7 +9,7 @@ class LoadingViewModelProvider : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return when (modelClass) { - LoadingViewModel::class.java -> LoadingViewModel(IonApplication.rootRepository) + LoadingViewModel::class.java -> LoadingViewModel(IonApplication.rootRepository, IonApplication.remoteConfigRepository) else -> throw IllegalArgumentException("Class $modelClass is not valid for this provider") } as T } diff --git a/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfig.kt b/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfig.kt new file mode 100644 index 0000000..9e411d2 --- /dev/null +++ b/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfig.kt @@ -0,0 +1,8 @@ +package org.ionproject.android.loading + +import com.fasterxml.jackson.annotation.JsonProperty + +data class RemoteConfig( + @JsonProperty("api_link") + var api_link: String +) \ No newline at end of file diff --git a/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfigRepository.kt b/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfigRepository.kt new file mode 100644 index 0000000..f72b07d --- /dev/null +++ b/Project/app/src/main/java/org/ionproject/android/loading/RemoteConfigRepository.kt @@ -0,0 +1,48 @@ +package org.ionproject.android.loading + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.ionproject.android.common.ionwebapi.* +import org.ionproject.android.settings.Preferences +import retrofit2.Retrofit +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.net.URI + +const val REMOTE_CONFIG_LINK = + "https://raw.githubusercontent.com/i-on-project/isel/main/Remote_Config.json" + +class RemoteConfigRepository(private val preferences: Preferences, mapper: JacksonIonMapper) { + + private val retrofit = Retrofit.Builder() + .baseUrl("https://raw.githubusercontent.com/") + .addConverterFactory(ScalarsConverterFactory.create()) + .build() + + private val service: IonService = retrofit.create(IonService::class.java) + + private val ionWebAPI = IonWebAPI(service, mapper) + + suspend fun getRemoteConfig() = + + withContext(Dispatchers.IO) { + + var remoteConfig: RemoteConfig? + + remoteConfig = ionWebAPI.getFromURI( + URI(REMOTE_CONFIG_LINK), + RemoteConfig::class.java, + "application/json" + ) + + val storedApiUrl = preferences.getWebApiHost() + + //update the stored API URL in case it changed + if (remoteConfig.api_link != storedApiUrl) { + preferences.saveWebApiHost(remoteConfig.api_link) + } + Log.d("API", "remoteConfig in repo: $remoteConfig") + + remoteConfig + } +} \ No newline at end of file diff --git a/Project/app/src/main/java/org/ionproject/android/settings/Preferences.kt b/Project/app/src/main/java/org/ionproject/android/settings/Preferences.kt index 220c50c..5232504 100644 --- a/Project/app/src/main/java/org/ionproject/android/settings/Preferences.kt +++ b/Project/app/src/main/java/org/ionproject/android/settings/Preferences.kt @@ -2,9 +2,11 @@ package org.ionproject.android.settings import android.content.Context import android.content.SharedPreferences +import org.ionproject.android.common.ionwebapi.WEB_API_HOST private const val SHARED_PREFERENCES_FILE = "org.ionproject.android.SHARED_PREFERENCES_FILE" private const val CALENDAR_TERM_KEY = "calendar_term_key" +private const val WEB_API_HOST_KEY = "webApi_host_key" /** * Preferences class, should contain only preferences related operations. @@ -27,4 +29,11 @@ class Preferences(context: Context) { putString(CALENDAR_TERM_KEY, calendarTerm) commit() } + + fun getWebApiHost() = sharedPref.getString(WEB_API_HOST_KEY, WEB_API_HOST) + + fun saveWebApiHost(newUrl: String) = with(sharedPref.edit()) { + putString(WEB_API_HOST_KEY, newUrl) + commit() + } } \ No newline at end of file diff --git a/Project/app/src/main/res/layout/activity_error.xml b/Project/app/src/main/res/layout/activity_error.xml index 8fd8040..86515f3 100644 --- a/Project/app/src/main/res/layout/activity_error.xml +++ b/Project/app/src/main/res/layout/activity_error.xml @@ -56,7 +56,6 @@ android:layout_marginTop="@dimen/fragments_padding_20" android:backgroundTint="@color/secondaryLightColor" android:text="@string/label_button_close_error" /> - diff --git a/Project/app/src/main/res/values-pt/strings.xml b/Project/app/src/main/res/values-pt/strings.xml index 28be4c9..31e1bb8 100644 --- a/Project/app/src/main/res/values-pt/strings.xml +++ b/Project/app/src/main/res/values-pt/strings.xml @@ -85,6 +85,7 @@ Pedimos desculpa mas ocorreu um erro. Tente reiniciar a aplicação. Este recurso não está disponível :( fechar + Atualizar e tentar de novo Nome não disponível A aplicação i-on Android não guarda dados pessoais do utilizador, guarda apenas informação obtida através da i-on Core API. Além disto, toda a informação guardada é local ao dispositivo e é removida assim que o utilizador desinstala a aplicação. Dados pessoais @@ -96,4 +97,6 @@ Exame Tarefa Notícia + A API i-on não está disponível, a redirecionar para o site do ISEL + Ficheiro de configuração remota não disponível, a redirecionar para o site do ISEL diff --git a/Project/app/src/main/res/values/strings.xml b/Project/app/src/main/res/values/strings.xml index 47e7dfe..091e37b 100644 --- a/Project/app/src/main/res/values/strings.xml +++ b/Project/app/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ export close + Update info and try again Application seems to be outdated :( Loading i-on Name not available @@ -124,4 +125,6 @@ The i-on Android application does not save any personal user data, it only saves information obtained from the i-on Core API. All the information that is saved stays only local to the device and once the application is uninstalled the information is deleted. i-on Android is a mobile application developed by members of the i-on iniciative. The iniciative was created with the goal of improving some academic aspects in the Polytechnic Institute of Engineering of Lisbon. The iniciative was created by teachers and students. All the code from this application and the rest of the projects from the iniciative is public so feel free to check it out by clicking the i-on logo below. + The i-on API is down, redirecting to ISEL\'s homepage + Couldn\'t update from Remote Config, redirecting to ISEL\'s homepage