diff --git a/.idea/assetWizardSettings.xml b/.idea/assetWizardSettings.xml index 6b96cf0..7944245 100644 --- a/.idea/assetWizardSettings.xml +++ b/.idea/assetWizardSettings.xml @@ -3,11 +3,49 @@ diff --git a/.idea/caches/build_file_checksums.ser b/.idea/caches/build_file_checksums.ser index ff025b7..f449c57 100644 Binary files a/.idea/caches/build_file_checksums.ser and b/.idea/caches/build_file_checksums.ser differ diff --git a/app/build.gradle b/app/build.gradle index b5f715d..fe82ac8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,6 +53,7 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" //Retrofit and GSON implementation 'com.squareup.retrofit2:retrofit:2.6.0' @@ -72,6 +73,10 @@ dependencies { implementation "org.kodein.di:kodein-di-generic-jvm:6.2.1" implementation "org.kodein.di:kodein-di-framework-android-x:6.2.1" + //mockito and livedata testing + testImplementation 'org.mockito:mockito-inline:2.13.0' + testImplementation 'androidx.arch.core:core-testing:2.1.0' + //Android Room implementation "androidx.room:room-runtime:2.2.0-rc01" implementation "androidx.room:room-ktx:2.2.0-rc01" @@ -83,5 +88,7 @@ dependencies { implementation "androidx.preference:preference-ktx:1.1.0" - + //mock websever for testing retrofit responses + testImplementation "com.squareup.okhttp3:mockwebserver:4.6.0" + testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4287140..6d243cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,7 @@ android:anyDensity="true" /> + android:resource="@xml/currency_app_widget_info" /> @@ -42,23 +42,7 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/AppClass.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/application/AppClass.kt similarity index 62% rename from app/src/main/java/com/appttude/h_mal/easycc/mvvm/AppClass.kt rename to app/src/main/java/com/appttude/h_mal/easycc/mvvm/application/AppClass.kt index 4997ebb..1e9e0b8 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/AppClass.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/application/AppClass.kt @@ -1,9 +1,10 @@ -package com.appttude.h_mal.easycc.mvvm +package com.appttude.h_mal.easycc.mvvm.application import android.app.Application -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl import com.appttude.h_mal.easycc.mvvm.data.network.NetworkConnectionInterceptor -import com.appttude.h_mal.easycc.mvvm.data.network.api.GetData +import com.appttude.h_mal.easycc.mvvm.data.network.QueryInterceptor +import com.appttude.h_mal.easycc.mvvm.data.network.api.CurrencyApi import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider import com.appttude.h_mal.easycc.mvvm.ui.app.MainViewModelFactory import com.appttude.h_mal.easycc.mvvm.ui.widget.WidgetViewModelFactory @@ -15,19 +16,18 @@ import org.kodein.di.generic.instance import org.kodein.di.generic.provider import org.kodein.di.generic.singleton -class AppClass : Application(), KodeinAware{ +class AppClass : Application(), KodeinAware { - override val kodein = Kodein.lazy { + override val kodein by Kodein.lazy { import(androidXModule(this@AppClass)) bind() from singleton { NetworkConnectionInterceptor(instance()) } - bind() from singleton { GetData(instance()) } + bind() from singleton { QueryInterceptor() } + bind() from singleton { CurrencyApi(instance(),instance()) } bind() from singleton { PreferenceProvider(instance()) } - bind() from singleton { Repository(instance(), instance(), instance()) } + bind() from singleton { RepositoryImpl(instance(), instance(), instance()) } bind() from provider { MainViewModelFactory(instance()) } bind() from provider { WidgetViewModelFactory(instance()) } } - - } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/Repository/Repository.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/Repository/Repository.kt deleted file mode 100644 index 1dd5fc1..0000000 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/Repository/Repository.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.appttude.h_mal.easycc.mvvm.data.Repository - -import android.content.Context -import com.appttude.h_mal.easycc.BuildConfig -import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider -import com.appttude.h_mal.easycc.R -import com.appttude.h_mal.easycc.mvvm.data.network.response.ResponseObject -import com.appttude.h_mal.easycc.mvvm.data.network.SafeApiRequest -import com.appttude.h_mal.easycc.mvvm.data.network.api.GetData - -class Repository ( - private val api: GetData, - private val prefs: PreferenceProvider, - context: Context -): SafeApiRequest(){ - - var ccApiKey = BuildConfig.CC_API_KEY - - private val appContext = context.applicationContext - - suspend fun getData(s1: String, s2: String): ResponseObject?{ - return apiRequest{ api.getCurrencyRate(convertPairsListToString(s1, s2),ccApiKey)} - } - - fun getConversionPair(): List { - return prefs.getConversionPair() - } - - fun setConversionPair(s1: String, s2: String){ - prefs.saveConversionPair(s1, s2) - } - - private fun convertPairsListToString(s1: String, s2: String): String = "${s1.substring(0,3)}_${s2.substring(0,3)}" - - fun getArrayList(): Array = appContext.resources.getStringArray(R.array.currency_arrays) - - fun getWidgetConversionPairs(id: Int): List = prefs.getWidgetConversionPair(id) - - fun setWidgetConversionPairs(s1: String, s2: String, id: Int) = prefs.saveWidgetConversionPair(s1, s2, id) - - fun removeWidgetConversionPairs(id: Int) = prefs.removeWidgetConversion(id) - -} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/QueryInterceptor.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/QueryInterceptor.kt new file mode 100644 index 0000000..5bd4f35 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/QueryInterceptor.kt @@ -0,0 +1,30 @@ +package com.appttude.h_mal.easycc.mvvm.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + + +class QueryInterceptor() : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val original: Request = chain.request() + val originalHttpUrl: HttpUrl = original.url() + + val url = originalHttpUrl.newBuilder() + .addQueryParameter("apikey", "a4f93cc2ff05dd772321") + .build() + + // Add amended Url back to request + val requestBuilder: Request.Builder = original.newBuilder() + .url(url) + + val request: Request = requestBuilder.build() + return chain.proceed(request) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/SafeApiRequest.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/SafeApiRequest.kt index 78a6a2e..fc949eb 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/SafeApiRequest.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/SafeApiRequest.kt @@ -1,29 +1,67 @@ package com.appttude.h_mal.easycc.mvvm.data.network +import android.util.Log import org.json.JSONException import org.json.JSONObject import retrofit2.Response import java.io.IOException +/** + * This abstract class extract objects from Retrofit [Response] + * or throws IOException if object does not exist + */ +private const val TAG = "SafeApiRequest" abstract class SafeApiRequest { - suspend fun apiRequest(call: suspend () -> Response) : T{ + suspend fun responseUnwrap( + call: suspend () -> Response + ): T { val response = call.invoke() - if(response.isSuccessful){ - return response.body()!! - }else{ - val error = response.errorBody()?.string() - val message = StringBuilder() - error?.let{ - try{ - message.append(JSONObject(it).getString("error")) - }catch(e: JSONException){ } - message.append("\n") - } - message.append("Error Code: ${response.code()}") - throw IOException(message.toString()) + if (response.isSuccessful) { + // return the object within the response body + return response.body()!! + } else { + // the response was unsuccessful + // throw IOException error + throw IOException(errorMessage(response)) } } + private fun errorMessage(errorResponse: Response): String { + val errorBody = errorResponse.errorBody()?.string() + val errorCode = "Error Code: ${errorResponse.code()}" + val errorMessageString = errorBody.getError() + + //build a log message to log in console + val log = if (errorMessageString.isNullOrEmpty()){ + errorCode + }else{ + StringBuilder() + .append(errorCode) + .append("\n") + .append(errorMessageString) + .toString() + } + Log.e("Api Response Error", log) + + //return error message + //if null return error code + return errorMessageString ?: errorCode + } + + @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") + private fun String?.getError(): String? { + this?.let { + try { + //convert response to JSON + //extract ["error"] from error body + return JSONObject(it).getString("error") + } catch (e: JSONException) { + Log.e(TAG, e.message) + } + } + return null + } + } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/GetData.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/CurrencyApi.kt similarity index 72% rename from app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/GetData.kt rename to app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/CurrencyApi.kt index 1952650..097eab1 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/GetData.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/api/CurrencyApi.kt @@ -2,6 +2,7 @@ package com.appttude.h_mal.easycc.mvvm.data.network.api import com.appttude.h_mal.easycc.mvvm.data.network.response.ResponseObject import com.appttude.h_mal.easycc.mvvm.data.network.NetworkConnectionInterceptor +import com.appttude.h_mal.easycc.mvvm.data.network.QueryInterceptor import okhttp3.OkHttpClient import retrofit2.Response import retrofit2.Retrofit @@ -10,15 +11,17 @@ import retrofit2.http.GET import retrofit2.http.Query -interface GetData { +interface CurrencyApi { companion object{ operator fun invoke( - networkConnectionInterceptor: NetworkConnectionInterceptor - ) : GetData{ + networkConnectionInterceptor: NetworkConnectionInterceptor, + queryInterceptor: QueryInterceptor + ) : CurrencyApi{ val okkHttpclient = OkHttpClient.Builder() .addNetworkInterceptor(networkConnectionInterceptor) + .addInterceptor(queryInterceptor) .build() return Retrofit.Builder() @@ -26,11 +29,11 @@ interface GetData { .baseUrl("https://free.currencyconverterapi.com/api/v3/") .addConverterFactory(GsonConverterFactory.create()) .build() - .create(GetData::class.java) + .create(CurrencyApi::class.java) } } @GET("convert?") - suspend fun getCurrencyRate(@Query("q") currency: String, @Query("apiKey") api: String): Response + suspend fun getCurrencyRate(@Query("q") currency: String): Response } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/response/ResponseObject.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/response/ResponseObject.kt index 2724399..6116763 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/response/ResponseObject.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/network/response/ResponseObject.kt @@ -7,5 +7,5 @@ class ResponseObject( @SerializedName("query") var query : Any, @SerializedName("results") - var results : Map + var results : Map? ) \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/prefs/PreferenceProvider.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/prefs/PreferenceProvider.kt index e609298..ce66276 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/prefs/PreferenceProvider.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/prefs/PreferenceProvider.kt @@ -32,11 +32,11 @@ class PreferenceProvider( ).apply() } - fun getConversionPair(): List { + fun getConversionPair(): Pair { val s1 = getLastConversionOne() val s2 = getLastConversionTwo() - return listOf(s1,s2) + return Pair(s1,s2) } private fun getLastConversionOne(): String? { @@ -57,11 +57,11 @@ class PreferenceProvider( ).apply() } - fun getWidgetConversionPair(id: Int): List { + fun getWidgetConversionPair(id: Int): Pair { val s1 = getWidgetLastConversionOne(id) val s2 = getWidgetLastConversionTwo(id) - return listOf(s1,s2) + return Pair(s1, s2) } private fun getWidgetLastConversionOne(id: Int): String? { diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/Repository.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/Repository.kt new file mode 100644 index 0000000..c134440 --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/Repository.kt @@ -0,0 +1,23 @@ +package com.appttude.h_mal.easycc.mvvm.data.repository + +import com.appttude.h_mal.easycc.R +import com.appttude.h_mal.easycc.mvvm.data.network.response.ResponseObject +import com.appttude.h_mal.easycc.mvvm.utils.convertPairsListToString + +interface Repository { + + suspend fun getData(s1: String, s2: String): ResponseObject + + fun getConversionPair(): Pair + + fun setConversionPair(s1: String, s2: String) + + fun getArrayList(): Array + + fun getWidgetConversionPairs(id: Int): Pair + + fun setWidgetConversionPairs(s1: String, s2: String, id: Int) + + fun removeWidgetConversionPairs(id: Int) + +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/RepositoryImpl.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/RepositoryImpl.kt new file mode 100644 index 0000000..3f603cf --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/data/repository/RepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.appttude.h_mal.easycc.mvvm.data.repository + +import android.content.Context +import com.appttude.h_mal.easycc.BuildConfig +import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider +import com.appttude.h_mal.easycc.R +import com.appttude.h_mal.easycc.mvvm.data.network.response.ResponseObject +import com.appttude.h_mal.easycc.mvvm.data.network.SafeApiRequest +import com.appttude.h_mal.easycc.mvvm.data.network.api.CurrencyApi +import com.appttude.h_mal.easycc.mvvm.utils.convertPairsListToString + + +class RepositoryImpl ( + private val api: CurrencyApi, + private val prefs: PreferenceProvider, + context: Context +):Repository, SafeApiRequest(){ + + private val appContext = context.applicationContext + + override suspend fun getData(s1: String, s2: String + ): ResponseObject{ + val currencyPair = convertPairsListToString(s1, s2) + return responseUnwrap{ + api.getCurrencyRate(currencyPair)} + } + + override fun getConversionPair(): Pair { + return prefs.getConversionPair() + } + + override fun setConversionPair(s1: String, s2: String){ + prefs.saveConversionPair(s1, s2) + } + + override fun getArrayList(): Array = + appContext.resources.getStringArray(R.array.currency_arrays) + + override fun getWidgetConversionPairs(id: Int): Pair = + prefs.getWidgetConversionPair(id) + + override fun setWidgetConversionPairs(s1: String, s2: String, id: Int) = + prefs.saveWidgetConversionPair(s1, s2, id) + + override fun removeWidgetConversionPairs(id: Int) = + prefs.removeWidgetConversion(id) + +} \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainActivity.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainActivity.kt index 1d9e2a5..e8d742c 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainActivity.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainActivity.kt @@ -7,15 +7,14 @@ import android.util.Log import android.view.View import android.view.WindowManager import android.widget.TextView -import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProviders import com.appttude.h_mal.easycc.R import com.appttude.h_mal.easycc.databinding.ActivityMainBinding -import com.appttude.h_mal.easycc.utils.DisplayToast -import com.appttude.h_mal.easycc.utils.clearEditText -import com.appttude.h_mal.easycc.utils.hideView +import com.appttude.h_mal.easycc.mvvm.utils.DisplayToast +import com.appttude.h_mal.easycc.mvvm.utils.clearEditText +import com.appttude.h_mal.easycc.mvvm.utils.hideView import kotlinx.android.synthetic.main.activity_main.* import org.kodein.di.KodeinAware import org.kodein.di.android.kodein diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModel.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModel.kt index a01a61e..4ce454e 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModel.kt @@ -3,13 +3,12 @@ package com.appttude.h_mal.easycc.mvvm.ui.app import android.widget.EditText import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.Repository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.IOException import java.text.DecimalFormat -import java.text.NumberFormat class MainViewModel( private val repository: Repository @@ -18,14 +17,20 @@ class MainViewModel( private val defaultValue by lazy { repository.getArrayList()[0] } private val conversionPairs by lazy { repository.getConversionPair() } - var rateIdFrom: String? = conversionPairs[0] ?: defaultValue - var rateIdTo: String? = conversionPairs[1] ?: defaultValue + var rateIdFrom: String? = conversionPairs.first ?: defaultValue + var rateIdTo: String? = conversionPairs.second ?: defaultValue var topVal: String? = null var bottomVal: String? = null var rateListener: RateListener? = null + //operation results livedata based on outcome of operation + val operationSuccess = MutableLiveData() + val operationFailed = MutableLiveData() + + val currencyRate = MutableLiveData() + private var conversionRate: Double = 0.00 fun getExchangeRate(){ @@ -40,15 +45,17 @@ class MainViewModel( val exchangeResponse = repository.getData(rateIdFrom!!, rateIdTo!!) repository.setConversionPair(rateIdFrom!!, rateIdTo!!) - exchangeResponse?.results?.iterator()?.next()?.value?.let { + exchangeResponse.results?.iterator()?.next()?.value?.let { rateListener?.onSuccess() conversionRate = it.value return@launch } - rateListener?.onFailure("Failed to retrieve rate") + }catch(e: IOException){ - rateListener?.onFailure(e.message!!) + rateListener?.onFailure(e.message ?: "Currency Retrieval failed") + return@launch } + rateListener?.onFailure("Failed to retrieve rate") } } diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModelFactory.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModelFactory.kt index 1eb26b6..49766a4 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModelFactory.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/app/MainViewModelFactory.kt @@ -2,11 +2,11 @@ package com.appttude.h_mal.easycc.mvvm.ui.app import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl @Suppress("UNCHECKED_CAST") class MainViewModelFactory ( - private val repository: Repository + private val repository: RepositoryImpl ): ViewModelProvider.NewInstanceFactory(){ override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetConfigureActivityKotlin.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetConfigureActivityKotlin.kt index 52de88c..7a157a2 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetConfigureActivityKotlin.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetConfigureActivityKotlin.kt @@ -8,7 +8,7 @@ import androidx.lifecycle.ViewModelProviders import com.appttude.h_mal.easycc.R import com.appttude.h_mal.easycc.databinding.CurrencyAppWidgetConfigureBinding import com.appttude.h_mal.easycc.mvvm.ui.app.RateListener -import com.appttude.h_mal.easycc.utils.DisplayToast +import com.appttude.h_mal.easycc.mvvm.utils.DisplayToast import org.kodein.di.KodeinAware import org.kodein.di.android.kodein import org.kodein.di.generic.instance diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetKotlin.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetKotlin.kt index 6b99f48..5cf7fed 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetKotlin.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/CurrencyAppWidgetKotlin.kt @@ -3,16 +3,16 @@ package com.appttude.h_mal.easycc.mvvm.ui.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.util.Log import android.widget.RemoteViews import android.widget.Toast -import com.appttude.h_mal.easycc.legacy.MainActivityJava import com.appttude.h_mal.easycc.R -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository -import com.appttude.h_mal.easycc.mvvm.data.network.NetworkConnectionInterceptor -import com.appttude.h_mal.easycc.mvvm.data.network.api.GetData -import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl +import com.appttude.h_mal.easycc.mvvm.ui.app.MainActivity +import com.appttude.h_mal.easycc.mvvm.utils.transformIntToArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -21,34 +21,51 @@ import org.kodein.di.LateInitKodein import org.kodein.di.generic.instance import java.io.IOException + /** * Implementation of App Widget functionality. * App Widget Configuration implemented in [CurrencyAppWidgetConfigureActivityKotlin] */ +private const val TAG = "CurrencyAppWidgetKotlin" class CurrencyAppWidgetKotlin : AppWidgetProvider() { + //DI with kodein to use in CurrencyAppWidgetKotlin private val kodein = LateInitKodein() - private val repository : Repository by kodein.instance() + private val repository : RepositoryImpl by kodein.instance() + //update trigger either on timed update or from from first start override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - kodein.baseKodein = (context.applicationContext as KodeinAware).kodein + Log.i(TAG,"onUpdate() appWidgetIds = ${appWidgetIds.size}") // There may be multiple widgets active, so update all of them for (appWidgetId in appWidgetIds) { updateAppWidget(context, appWidgetManager, appWidgetId) } + super.onUpdate(context, appWidgetManager, appWidgetIds) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { - kodein.baseKodein = (context.applicationContext as KodeinAware).kodein // When the user deletes the widget, delete the preference associated with it. for (appWidgetId in appWidgetIds) { repository.removeWidgetConversionPairs(appWidgetId) } + super.onDeleted(context, appWidgetIds) } override fun onEnabled(context: Context) { - kodein.baseKodein = (context.applicationContext as KodeinAware).kodein // Enter relevant functionality for when the first widget is created + AppWidgetManager.getInstance(context).apply { + val thisAppWidget = ComponentName(context.packageName, CurrencyAppWidgetKotlin::class.java.name) + val appWidgetIds = getAppWidgetIds(thisAppWidget) + onUpdate(context, this, appWidgetIds) + } + super.onEnabled(context) + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null){ return } + kodein.baseKodein = (context.applicationContext as KodeinAware).kodein + + super.onReceive(context, intent) } override fun onDisabled(context: Context) { @@ -57,58 +74,75 @@ class CurrencyAppWidgetKotlin : AppWidgetProvider() { } - fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - //todo: get value from repository + private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { val stringList = repository.getWidgetConversionPairs(appWidgetId) - val s1 = stringList[0] - val s2 = stringList[1] + val s1 = stringList.first + val s2 = stringList.second // Construct the RemoteViews object val views = RemoteViews(context.packageName, R.layout.currency_app_widget) - views.setTextViewText(R.id.exchangeName, "Rates") - views.setTextViewText(R.id.exchangeRate, "not set") - //todo: async task to get rate CoroutineScope(Dispatchers.Main).launch { try { val response = repository.getData(s1!!.substring(0,3),s2!!.substring(0,3)) - response?.results?.iterator()?.next()?.value?.let { + response.results?.iterator()?.next()?.value?.let { val titleString = "${it.fr}${it.to}" views.setTextViewText(R.id.exchangeName, titleString) views.setTextViewText(R.id.exchangeRate, it.value.toString()) } }catch (io : IOException){ + Log.i("WidgetClass",io.message ?: "Failed") Toast.makeText(context,io.message, Toast.LENGTH_LONG).show() }finally { - // Instruct the widget manager to update the widget - appWidgetManager.updateAppWidget(appWidgetId, views) - - val opacity = 0.3f //opacity = 0: fully transparent, opacity = 1: no transparancy - val backgroundColor = 0x000000 //background color (here black) - - views.setInt(R.id.widget_view, "setBackgroundColor", (opacity * 0xFF).toInt() shl 24 or backgroundColor) - - val clickIntentTemplate = Intent(context, MainActivityJava::class.java).apply { - action = Intent.ACTION_MAIN - addCategory(Intent.CATEGORY_LAUNCHER) - putExtra("parse_1", s1) - putExtra("parse_2", s2) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + setUpdateIntent(context, appWidgetId).let { + //set the pending intent to the icon + views.setImageViewResource(R.id.refresh_icon, R.drawable.ic_refresh_white_24dp) + views.setOnClickPendingIntent(R.id.refresh_icon, it) } - val configPendingIntent = PendingIntent.getActivity(context, 0, clickIntentTemplate, PendingIntent.FLAG_UPDATE_CURRENT) + val clickIntentTemplate = clickingIntent(context, s1, s2) + + val configPendingIntent = + PendingIntent.getActivity( + context, appWidgetId, clickIntentTemplate, + PendingIntent.FLAG_UPDATE_CURRENT) + views.setOnClickPendingIntent(R.id.widget_view, configPendingIntent) + + // Instruct the widget manager to update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) } } } - fun setupRepository(context: Context): Repository { - val networkInterceptor = NetworkConnectionInterceptor(context) - val getData = GetData(networkInterceptor) - val prefs = PreferenceProvider(context) - return Repository(getData,prefs,context) + private fun setUpdateIntent(context: Context, appWidgetId: Int): PendingIntent? { + //Create update intent for refresh icon + val updateIntent = Intent( + context, CurrencyAppWidgetKotlin::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, transformIntToArray(appWidgetId)) + } + //add previous intent to this pending intent + return PendingIntent.getBroadcast( + context, + appWidgetId, + updateIntent, + PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun clickingIntent( + context: Context, + s1: String?, s2: String? + ): Intent { + return Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + putExtra("parse_1", s1) + putExtra("parse_2", s2) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } } } diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetSubmitDialog.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetSubmitDialog.kt index 4b2d8e8..e8f84de 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetSubmitDialog.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetSubmitDialog.kt @@ -6,11 +6,13 @@ import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle import com.appttude.h_mal.easycc.R +import com.appttude.h_mal.easycc.mvvm.utils.transformIntToArray import kotlinx.android.synthetic.main.confirm_dialog.* -/* -widget for when submitting the completed selections +/** + * Dialog created when submitting the completed selections + * in [CurrencyAppWidgetConfigureActivityKotlin] */ class WidgetSubmitDialog( private val activity: Activity, @@ -21,26 +23,32 @@ class WidgetSubmitDialog( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.confirm_dialog) - -// requestWindowFeature(Window.FEATURE_NO_TITLE) + // layer behind dialog to be transparent window!!.setBackgroundDrawableResource(android.R.color.transparent) + // Dialog cannot be cancelled by clicking away setCancelable(false) - //todo: amend widget text confirm_text.text = StringBuilder().append("Create widget for ") .append(viewModel.getWidgetStringName()) .append("?").toString() confirm_yes.setOnClickListener { - viewModel.setWidgetStored() + // It is the responsibility of the configuration activity to update the app widget + // Send update broadcast to widget app class + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, + context, + CurrencyAppWidgetKotlin::class.java).apply { + // Save current widget pairs + viewModel.setWidgetStored() + // Put current app widget ID into extras and send broadcast + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, transformIntToArray(appWidgetId) ) + activity.sendBroadcast(this) + } - val intent = Intent(context, CurrencyAppWidgetKotlin::class.java) - intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, IntArray(appWidgetId)) - context.sendBroadcast(intent) // Make sure we pass back the original appWidgetId - val resultValue = Intent() + val resultValue = activity.intent resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) activity.setResult(Activity.RESULT_OK, resultValue) activity.finish() diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModel.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModel.kt index 6d1fbe3..8576a2e 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModel.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModel.kt @@ -4,11 +4,11 @@ import android.view.View import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl import com.appttude.h_mal.easycc.mvvm.ui.app.RateListener class WidgetViewModel( - private val repository: Repository + private val repository: RepositoryImpl ) : ViewModel(){ var rateListener: RateListener? = null @@ -22,10 +22,9 @@ class WidgetViewModel( appWidgetId = appId val widgetString = getWidgetStored(appId) - if (widgetString.isNotEmpty()){ - rateIdFrom.value = widgetString[0] - rateIdTo.value = widgetString[1] - } + rateIdFrom.value = widgetString.first + rateIdTo.value = widgetString.second + } fun selectCurrencyOnClick(view: View){ @@ -80,15 +79,5 @@ class WidgetViewModel( private fun String.trimToThree() = this.substring(0,3) - private fun arrayEntry(s: String?): String? { - val strings = repository.getArrayList() - var returnString: String? = strings[0] - for (string in strings) { - if (s == string.substring(0, 3)) { - returnString = string - } - } - return returnString - } } \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModelFactory.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModelFactory.kt index a04e57a..5d90697 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModelFactory.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/ui/widget/WidgetViewModelFactory.kt @@ -2,11 +2,11 @@ package com.appttude.h_mal.easycc.mvvm.ui.widget import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import com.appttude.h_mal.easycc.mvvm.data.Repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl @Suppress("UNCHECKED_CAST") class WidgetViewModelFactory ( - private val repository: Repository + private val repository: RepositoryImpl ): ViewModelProvider.NewInstanceFactory(){ override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/PrimitiveUtils.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/PrimitiveUtils.kt new file mode 100644 index 0000000..085291d --- /dev/null +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/PrimitiveUtils.kt @@ -0,0 +1,15 @@ +package com.appttude.h_mal.easycc.mvvm.utils + +fun transformIntToArray(int: Int): IntArray{ + return intArrayOf(int) +} + +fun String.trimToThree(): String{ + if (this.length > 3){ + return this.substring(0, 3) + } + return this +} + +fun convertPairsListToString(s1: String, s2: String): String = + "${s1.trimToThree()}_${s2.trimToThree()}" \ No newline at end of file diff --git a/app/src/main/java/com/appttude/h_mal/easycc/utils/ViewUtils.kt b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/ViewUtils.kt similarity index 88% rename from app/src/main/java/com/appttude/h_mal/easycc/utils/ViewUtils.kt rename to app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/ViewUtils.kt index bb9ab0c..448fd17 100644 --- a/app/src/main/java/com/appttude/h_mal/easycc/utils/ViewUtils.kt +++ b/app/src/main/java/com/appttude/h_mal/easycc/mvvm/utils/ViewUtils.kt @@ -1,4 +1,4 @@ -package com.appttude.h_mal.easycc.utils +package com.appttude.h_mal.easycc.mvvm.utils import android.content.Context import android.view.View @@ -15,4 +15,4 @@ fun View.hideView(vis : Boolean){ fun Context.DisplayToast(message: String){ Toast.makeText(this, message, Toast.LENGTH_LONG).show() -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 0000000..cc2d1e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/confirm_dialog.xml b/app/src/main/res/layout/confirm_dialog.xml index 1528257..a063840 100644 --- a/app/src/main/res/layout/confirm_dialog.xml +++ b/app/src/main/res/layout/confirm_dialog.xml @@ -15,15 +15,17 @@ @@ -37,7 +39,11 @@ android:id="@+id/confirm_yes" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_margin="8dp" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:paddingLeft="12dp" + android:paddingRight="12dp" + android:textStyle="bold" android:text="@android:string/yes" android:textColor="@color/colour_five" /> @@ -46,6 +52,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" + android:textStyle="bold" android:text="@android:string/no" android:textColor="@color/colour_five" /> diff --git a/app/src/main/res/layout/currency_app_widget.xml b/app/src/main/res/layout/currency_app_widget.xml index 05edee8..42be33e 100644 --- a/app/src/main/res/layout/currency_app_widget.xml +++ b/app/src/main/res/layout/currency_app_widget.xml @@ -2,23 +2,38 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/widget_view" android:layout_width="match_parent" + tools:layout_width="110dp" android:layout_height="72dp" - android:orientation="vertical"> + android:orientation="vertical" + android:background="#4D000000"> - - + android:layout_marginBottom="3dp"> + + + + tools:text="0.526462" /> \ No newline at end of file diff --git a/app/src/main/res/xml/currency_app_widget_info.xml b/app/src/main/res/xml/currency_app_widget_info.xml index b325a13..3d0ca83 100644 --- a/app/src/main/res/xml/currency_app_widget_info.xml +++ b/app/src/main/res/xml/currency_app_widget_info.xml @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/app/src/main/res/xml/currency_kotlin_app_widget_info.xml b/app/src/main/res/xml/currency_kotlin_app_widget_info.xml index ec7724a..8094031 100644 --- a/app/src/main/res/xml/currency_kotlin_app_widget_info.xml +++ b/app/src/main/res/xml/currency_kotlin_app_widget_info.xml @@ -7,5 +7,5 @@ android:minHeight="40dp" android:previewImage="@drawable/example_appwidget_preview" android:resizeMode="horizontal|vertical" - android:updatePeriodMillis="86400000" - android:widgetCategory="home_screen|keyguard"> \ No newline at end of file + android:updatePeriodMillis="3600000" + android:widgetCategory="home_screen|keyguard"/> \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryNetworkTest.kt b/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryNetworkTest.kt new file mode 100644 index 0000000..ee3a4ec --- /dev/null +++ b/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryNetworkTest.kt @@ -0,0 +1,86 @@ +package com.appttude.h_mal.easycc.repository + +import android.content.Context +import com.appttude.h_mal.easycc.BuildConfig +import com.appttude.h_mal.easycc.mvvm.data.network.api.CurrencyApi +import com.appttude.h_mal.easycc.mvvm.data.network.response.ResponseObject +import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider +import com.appttude.h_mal.easycc.mvvm.data.repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl +import com.appttude.h_mal.easycc.mvvm.utils.convertPairsListToString +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import retrofit2.Response + +import java.io.IOException +import kotlin.test.assertFailsWith + + +class RepositoryNetworkTest{ + + lateinit var repository: Repository + + @Mock + lateinit var api: CurrencyApi + @Mock + lateinit var prefs: PreferenceProvider + @Mock + lateinit var context: Context + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + repository = RepositoryImpl(api, prefs, context) + } + + @Test + fun getRateFromApi_positiveResponse() = runBlocking { + //GIVEN - Create query string + val s1 = "AUD - Australian Dollar" + val s2 = "GBP - British Pound" + val query = convertPairsListToString(s1, s2) + //create a successful retrofit response + val mockCurrencyResponse = mock(ResponseObject::class.java) + val re = Response.success(mockCurrencyResponse) + + //WHEN - loginApiRequest to return a successful response + Mockito.`when`(api.getCurrencyRate(query)).thenReturn(re) + + //THEN - the unwrapped login response contains the correct values + val currencyResponse = repository.getData(s1,s2) + assertNotNull(currencyResponse) + assertEquals(currencyResponse, mockCurrencyResponse) + } + + @Test + fun loginUser_negativeResponse() = runBlocking { + //GIVEN + val s1 = "AUD - Australian Dollar" + val s2 = "GBP - British Pound" + val query = convertPairsListToString(s1, s2) + //mock retrofit error response + val mockBody = mock(ResponseBody::class.java) + val mockRaw = mock(okhttp3.Response::class.java) + val re = Response.error(mockBody, mockRaw) + + //WHEN + Mockito.`when`(api.getCurrencyRate(query)).thenAnswer { re } + + //THEN - assert exception is not null + val ioExceptionReturned = assertFailsWith { + repository.getData(s1, s2) + } + assertNotNull(ioExceptionReturned) + assertNotNull(ioExceptionReturned.message) + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryStorageTest.kt b/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryStorageTest.kt new file mode 100644 index 0000000..4efce75 --- /dev/null +++ b/app/src/test/java/com/appttude/h_mal/easycc/repository/RepositoryStorageTest.kt @@ -0,0 +1,62 @@ +package com.appttude.h_mal.easycc.repository + +import android.content.Context +import com.appttude.h_mal.easycc.mvvm.data.network.api.CurrencyApi +import com.appttude.h_mal.easycc.mvvm.data.prefs.PreferenceProvider +import com.appttude.h_mal.easycc.mvvm.data.repository.Repository +import com.appttude.h_mal.easycc.mvvm.data.repository.RepositoryImpl +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import kotlin.test.assertEquals + +class RepositoryStorageTest { + + lateinit var repository: Repository + + @Mock + lateinit var api: CurrencyApi + @Mock + lateinit var prefs: PreferenceProvider + @Mock + lateinit var context: Context + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + repository = RepositoryImpl(api, prefs, context) + } + + @Test + fun saveAndRetrieve_PositiveResponse() { + //GIVEN + val s1 = "AUD - Australian Dollar" + val s2 = "GBP - British Pound" + val pair = Pair(s1, s2) + repository.setConversionPair(s1, s2) + + //WHEN + Mockito.`when`(prefs.getConversionPair()).thenReturn(pair) + + //THEN + assertEquals(pair, repository.getConversionPair()) + } + + @Test + fun saveAndRetrieveCredentials_PositiveResponse() { + //GIVEN + val s1 = "AUD - Australian Dollar" + val s2 = "GBP - British Pound" + val id = 1234 + val pair = Pair(s1, s2) + repository.setWidgetConversionPairs("forename", "Surname", id) + + //WHEN + Mockito.`when`(prefs.getWidgetConversionPair(id)).thenReturn(pair) + + //THEN + assertEquals(pair, repository.getWidgetConversionPairs(id)) + } +} \ No newline at end of file