Unit tests added

Query interceptor added
Unit tests created
 - Repository test network
 - Repository test storage
This commit is contained in:
2020-05-15 21:13:05 +01:00
parent 9753312573
commit 7308d3f9df
30 changed files with 554 additions and 199 deletions

View File

@@ -3,11 +3,49 @@
<component name="WizardSettings">
<option name="children">
<map>
<entry key="vectorWizard">
<entry key="imageWizard">
<value>
<PersistentState />
</value>
</entry>
<entry key="vectorWizard">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="vectorAssetStep">
<value>
<PersistentState>
<option name="children">
<map>
<entry key="clipartAsset">
<value>
<PersistentState>
<option name="values">
<map>
<entry key="url" value="jar:file:/C:/Program%20Files/Android/Android%20Studio/plugins/android/lib/android.jar!/images/material_design_icons/navigation/ic_refresh_black_24dp.xml" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
<option name="values">
<map>
<entry key="color" value="ffffff" />
<entry key="outputName" value="ic_refresh_white_24dp" />
<entry key="sourceFile" value="C:\Users\h_mal" />
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</PersistentState>
</value>
</entry>
</map>
</option>
</component>

Binary file not shown.

View File

@@ -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"
}

View File

@@ -13,7 +13,7 @@
android:anyDensity="true" />
<application
android:name=".mvvm.AppClass"
android:name=".mvvm.application.AppClass"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -27,7 +27,7 @@
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/currency_kotlin_app_widget_info" />
android:resource="@xml/currency_app_widget_info" />
</receiver>
<activity android:name="com.appttude.h_mal.easycc.mvvm.ui.widget.CurrencyAppWidgetConfigureActivityKotlin">
@@ -42,23 +42,7 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".legacy.MainActivityJava"></activity>
<receiver android:name=".legacy.CurrencyAppWidget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/currency_app_widget_info" />
</receiver>
<activity android:name=".legacy.CurrencyAppWidgetConfigureActivity">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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()) }
}
}

View File

@@ -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<String?> {
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<String> = appContext.resources.getStringArray(R.array.currency_arrays)
fun getWidgetConversionPairs(id: Int): List<String?> = prefs.getWidgetConversionPair(id)
fun setWidgetConversionPairs(s1: String, s2: String, id: Int) = prefs.saveWidgetConversionPair(s1, s2, id)
fun removeWidgetConversionPairs(id: Int) = prefs.removeWidgetConversion(id)
}

View File

@@ -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)
}
}

View File

@@ -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<T: Any> apiRequest(call: suspend () -> Response<T>) : T{
suspend fun <T : Any> responseUnwrap(
call: suspend () -> Response<T>
): 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 <T> errorMessage(errorResponse: Response<T>): 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
}
}

View File

@@ -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<ResponseObject>
suspend fun getCurrencyRate(@Query("q") currency: String): Response<ResponseObject>
}

View File

@@ -7,5 +7,5 @@ class ResponseObject(
@SerializedName("query")
var query : Any,
@SerializedName("results")
var results : Map<String, CurrencyObject>
var results : Map<String, CurrencyObject>?
)

View File

@@ -32,11 +32,11 @@ class PreferenceProvider(
).apply()
}
fun getConversionPair(): List<String?> {
fun getConversionPair(): Pair<String?, String?> {
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<String?> {
fun getWidgetConversionPair(id: Int): Pair<String?, String?> {
val s1 = getWidgetLastConversionOne(id)
val s2 = getWidgetLastConversionTwo(id)
return listOf(s1,s2)
return Pair(s1, s2)
}
private fun getWidgetLastConversionOne(id: Int): String? {

View File

@@ -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<String?, String?>
fun setConversionPair(s1: String, s2: String)
fun getArrayList(): Array<String>
fun getWidgetConversionPairs(id: Int): Pair<String?, String?>
fun setWidgetConversionPairs(s1: String, s2: String, id: Int)
fun removeWidgetConversionPairs(id: Int)
}

View File

@@ -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<String?, String?> {
return prefs.getConversionPair()
}
override fun setConversionPair(s1: String, s2: String){
prefs.saveConversionPair(s1, s2)
}
override fun getArrayList(): Array<String> =
appContext.resources.getStringArray(R.array.currency_arrays)
override fun getWidgetConversionPairs(id: Int): Pair<String?, String?> =
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)
}

View File

@@ -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

View File

@@ -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<Boolean>()
val operationFailed = MutableLiveData<String>()
val currencyRate = MutableLiveData<Double>()
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")
}
}

View File

@@ -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 <T : ViewModel?> create(modelClass: Class<T>): T {

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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 <T : ViewModel?> create(modelClass: Class<T>): T {

View File

@@ -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()}"

View File

@@ -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

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@@ -15,15 +15,17 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
android:layout_margin="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/confirm_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginTop="12dp"
android:layout_marginLeft="12dp"
android:layout_marginRight="36dp"
android:layout_marginBottom="24dp"
android:text="Create widget for AUDGBP?"
android:textColor="@color/colour_five" />
@@ -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" />

View File

@@ -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">
<TextView
android:id="@+id/exchangeName"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="3dp"
android:layout_weight="1"
android:autoSizeMaxTextSize="100sp"
android:autoSizeMinTextSize="8sp"
android:autoSizeStepGranularity="2sp"
android:autoSizeTextType="uniform"
android:gravity="center"
android:textColor="#ffffff"
tools:text="AUDGBP" />
android:layout_marginBottom="3dp">
<TextView
android:id="@+id/exchangeName"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:autoSizeMaxTextSize="100sp"
android:autoSizeMinTextSize="6sp"
android:autoSizeStepGranularity="2sp"
android:autoSizeTextType="uniform"
android:textColor="#ffffff"
android:text="Rate not set"
tools:text="AUDGBP" />
<ImageView
android:id="@+id/refresh_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:src="@drawable/ic_refresh_white_24dp"
android:layout_alignParentTop="true"
android:layout_alignParentRight="true"
android:layout_alignBottom="@id/exchangeName"
android:adjustViewBounds="true"/>
</RelativeLayout>
<TextView
android:id="@+id/exchangeRate"
@@ -32,5 +47,5 @@
android:gravity="center"
android:textColor="#ffffff"
android:textStyle="bold"
tools:text="0.56" />
tools:text="0.526462" />
</LinearLayout>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="com.appttude.h_mal.easycc.legacy.CurrencyAppWidgetConfigureActivity"
android:configure="com.appttude.h_mal.easycc.mvvm.ui.widget.CurrencyAppWidgetConfigureActivityKotlin"
android:initialKeyguardLayout="@layout/currency_app_widget"
android:initialLayout="@layout/currency_app_widget"
android:minHeight="40dp"
@@ -9,7 +9,7 @@
android:minResizeWidth="40dp"
android:previewImage="@drawable/easyycc_widget_preview"
android:resizeMode="horizontal"
android:updatePeriodMillis="86400000"
android:updatePeriodMillis="21600000"
android:widgetCategory="home_screen">
</appwidget-provider>

View File

@@ -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"></appwidget-provider>
android:updatePeriodMillis="3600000"
android:widgetCategory="home_screen|keyguard"/>

View File

@@ -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<String>(mockBody, mockRaw)
//WHEN
Mockito.`when`(api.getCurrencyRate(query)).thenAnswer { re }
//THEN - assert exception is not null
val ioExceptionReturned = assertFailsWith<IOException> {
repository.getData(s1, s2)
}
assertNotNull(ioExceptionReturned)
assertNotNull(ioExceptionReturned.message)
}
}

View File

@@ -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))
}
}