diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/KeyValueStorageManager.kt b/requestly-android-core/src/main/java/io/requestly/android/core/KeyValueStorageManager.kt index 354029b..0ded2b1 100644 --- a/requestly-android-core/src/main/java/io/requestly/android/core/KeyValueStorageManager.kt +++ b/requestly-android-core/src/main/java/io/requestly/android/core/KeyValueStorageManager.kt @@ -3,19 +3,64 @@ package io.requestly.android.core import android.content.Context import android.content.SharedPreferences import com.google.gson.Gson +import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken +import io.requestly.android.core.modules.apiModifier.dao.Rule +import io.requestly.android.core.modules.apiModifier.dao.RuleJsonDeserializer +import java.io.File +import java.lang.ref.WeakReference + +data class SharedPrefFileData( + val key: String, + val value: Any?, + val dataType: SharedPrefType?, + val fileName: String, +) + +enum class SharedPrefType { + STRING { + override fun toString() = "String" + }, + INTEGER { + override fun toString() = "Integer" + }, + DOUBLE { + override fun toString() = "Double" + }, + LONG { + override fun toString() = "Long" + }, + BOOLEAN { + override fun toString() = "Boolean" + }, + STRING_SET { + override fun toString() = "StringSet" + }; + + abstract override fun toString(): String +} object KeyValueStorageManager { - private const val FILE_NAME = "io.requestly.requestly_pref_file" + private const val REQUESTLY_SHARED_PREF_FILE_PREFIX = "io.requestly." + private const val FILE_NAME = "${REQUESTLY_SHARED_PREF_FILE_PREFIX}requestly_pref_file" + /** + * Full path to the default directory assigned to the package for its + * persistent data. + */ + private lateinit var mContext: WeakReference private lateinit var mSharedPref: SharedPreferences private lateinit var gson: Gson - private var changeListeners: MutableList = mutableListOf() + private var changeListeners: MutableList = + mutableListOf() fun initialize(context: Context) { + mContext = WeakReference(context) mSharedPref = context.getSharedPreferences(FILE_NAME, 0) - gson = Gson() + val builder = GsonBuilder() + builder.registerTypeAdapter(Rule::class.java, RuleJsonDeserializer()) + gson = builder.create() } fun getString(key: String, defaultValue: String? = null): String? { @@ -50,6 +95,63 @@ object KeyValueStorageManager { return gson.fromJson(json, typeToken.type) } + fun deleteKeyFromFile(keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + + with(pref.edit()) { + this.remove(keyName) + this.commit() + } + } + + fun putStringInfile(value: String, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putString(keyName, value) + this.commit() + } + } + + fun putStringSetInfile(value: Set, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putStringSet(keyName, value) + this.commit() + } + } + + fun putIntegerInfile(value: Int, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putInt(keyName, value) + this.commit() + } + } + + fun putDoubleInfile(value: Double, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putFloat(keyName, value.toFloat()) + this.commit() + } + } + + fun putLongInfile(value: Long, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putLong(keyName, value) + this.commit() + } + } + + fun putBooleanInfile(value: Boolean, keyName: String, fileName: String) { + val pref = mContext.get()?.getSharedPreferences(fileName, 0) ?: return + with(pref.edit()) { + this.putBoolean(keyName, value) + this.commit() + } + } + fun registerChangeListener(forKey: String, changeListener: () -> Unit) { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key === forKey) { @@ -59,4 +161,45 @@ object KeyValueStorageManager { changeListeners.add(listener) mSharedPref.registerOnSharedPreferenceChangeListener(listener) } + + @Suppress("CANDIDATE_CHOSEN_USING_OVERLOAD_RESOLUTION_BY_LAMBDA_ANNOTATION") + fun fetchDataFromAllSharedPrefFiles(): List { + val context = mContext.get() ?: return emptyList() + + val prefsDir = File(context.applicationInfo.dataDir, "shared_prefs") + + if (!prefsDir.exists() || !prefsDir.isDirectory) return emptyList() + + return prefsDir + .list() + ?.filter { !it.startsWith(REQUESTLY_SHARED_PREF_FILE_PREFIX) } + ?.flatMap { filename -> + val fileNameWithOutExtension = filename.removeSuffix(".xml") + val thisPref = context.getSharedPreferences(fileNameWithOutExtension, 0) + return@flatMap thisPref.all.map { entry -> + SharedPrefFileData( + key = entry.key, + value = entry.value, + dataType = detectType(entry.value), + fileName = fileNameWithOutExtension, + ) + } + } ?: emptyList() + } + + private fun detectType(value: Any?): SharedPrefType? { + if (value == null) return null + + return when (value::class.simpleName) { + "Int" -> SharedPrefType.INTEGER + "Float" -> SharedPrefType.DOUBLE + "Long" -> SharedPrefType.LONG + "HashSet" -> SharedPrefType.STRING_SET + "Boolean" -> SharedPrefType.BOOLEAN + "String" -> SharedPrefType.STRING + else -> null + } + } } + + diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragment.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragment.kt new file mode 100644 index 0000000..d451920 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragment.kt @@ -0,0 +1,258 @@ +package io.requestly.android.core.modules.apiModifier + +import android.annotation.SuppressLint +import android.app.Dialog +import android.os.Bundle +import android.view.* +import android.widget.* +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import io.requestly.android.core.R +import io.requestly.android.core.databinding.FragmentApiModifierBinding +import io.requestly.android.core.modules.apiModifier.dao.* +import io.requestly.android.core.modules.loadSimpleYesNoAlertDialog +import kotlin.Pair + +typealias OnSaveClickFnType = (T) -> Unit + +class ApiModifierFragment : Fragment() { + + private lateinit var mainBinding: FragmentApiModifierBinding + private val viewModel: ApiModifierFragmentViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mainBinding = FragmentApiModifierBinding.inflate(layoutInflater) + + // Inflate the layout for this fragment + initRecyclerView() + + return mainBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val provider = object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.host_switcher_fragment_menu, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.createHostSwitchRule -> { + loadAddNewHostSwitchItemDialog(null) { (t1, t2) -> + viewModel.createReplaceRule(startingText = t1, provisionalText = t2) + } + true + } + R.id.createMockRule -> { + loadAddNewMockRuleItemDialog( + null, + HttpVerb.values().asList(), + SourceOperator.values().asList() + ) { (verb, urlContainingText, destinationUrl) -> + viewModel.createRedirectRule(verb, urlContainingText, destinationUrl) + } + true + } + else -> { + false + } + } + } + } + requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + @SuppressLint("NotifyDataSetChanged") + private fun initRecyclerView() { + mainBinding.hostSwitcherRulesRecyclerView.layoutManager = + LinearLayoutManager(requireContext()) + val mapper: (SwitchingRule) -> ApiModifierRuleItemModel? = mapperFunc@{ + + val rule = it.pairs.firstOrNull() ?: return@mapperFunc null + + when (rule) { + is Redirect -> { + return@mapperFunc ApiModifierRuleItemModel( + ruleTypeText = "Mock Rule", + httpVerbText = rule.source + .filters.firstOrNull() + ?.requestMethod?.firstOrNull() + ?.toString(), + operatorText = rule.source.operator.toString(), + sourceUrlText = rule.source.value, + targetUrlText = rule.destination, + targetUrlGuideText = "Load Response from", + isActive = it.isActive, + onSwitchStateChangeListener = { boolValue -> + viewModel.editSwitchState(it.id, boolValue) + }, + onEditClickListener = { + loadAddNewMockRuleItemDialog( + rule = rule, + httpVerbs = HttpVerb.values().asList(), + operators = SourceOperator.values().asList() + ) { (verb, urlContainingText, destinationUrl) -> + viewModel.editRedirectRule( + verb, + urlContainingText, + destinationUrl, + it.id + ) + } + }, + onDeleteClickListener = { + loadSimpleYesNoAlertDialog( + context = requireContext(), + message = "Are you sure you want to delete this?", + onPositiveButtonClick = { viewModel.deleteItem(it.id) } + ) + } + ) + } + is Replace -> { + return@mapperFunc ApiModifierRuleItemModel( + ruleTypeText = "Switch Rule", + httpVerbText = null, + operatorText = rule.source.operator.toString(), + sourceUrlText = rule.from, + targetUrlText = rule.to, + targetUrlGuideText = "Replace with", + isActive = it.isActive, + onSwitchStateChangeListener = { boolValue -> + viewModel.editSwitchState(it.id, boolValue) + }, + onEditClickListener = { + loadAddNewHostSwitchItemDialog(rule) { (t1, t2) -> + viewModel.editReplaceRule(t1, t2, it.id) + } + }, + onDeleteClickListener = { + loadSimpleYesNoAlertDialog( + context = requireContext(), + message = "Are you sure you want to delete this?", + onPositiveButtonClick = { viewModel.deleteItem(it.id) } + ) + } + ) + } + } + } + val items: List = + viewModel.rulesListLive.value?.map(mapper)?.filterNotNull() ?: emptyList() + val adaptor = ApiModifierRuleItemAdaptor(items) + viewModel.rulesListLive.observe(viewLifecycleOwner) { + adaptor.items = it.map(mapper).filterNotNull() + // Its fine to use notifyDataSetChanged here. + // Data size is small enough to not cause Frame skips or lags. + adaptor.notifyDataSetChanged() + } + mainBinding.hostSwitcherRulesRecyclerView.adapter = adaptor + } + + private fun loadAddNewHostSwitchItemDialog( + rule: Replace?, + onSaveClick: OnSaveClickFnType> + ) { + val dialog = Dialog(requireContext()) + dialog.setContentView(R.layout.api_modifier_new_replace_rule_dialog) + dialog.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + dialog.setCancelable(false) + dialog.show() + + val saveButton = dialog.findViewById(R.id.saveButton) + val cancelButton = dialog.findViewById(R.id.cancelButton) + val startingEditText = dialog.findViewById(R.id.startingEditText) + val provisionalEditText = dialog.findViewById(R.id.provisionalEditText) + + startingEditText.setText(rule?.from ?: "", TextView.BufferType.EDITABLE) + provisionalEditText.setText(rule?.to ?: "", TextView.BufferType.EDITABLE) + saveButton.setOnClickListener { + onSaveClick(Pair(startingEditText.text.toString(), provisionalEditText.text.toString())) + dialog.hide() + } + cancelButton.setOnClickListener { + dialog.hide() + } + } + + private fun loadAddNewMockRuleItemDialog( + rule: Redirect?, + httpVerbs: List, + operators: List, + onSaveClick: OnSaveClickFnType>, + ) { + var selectedMethod: HttpVerb = + rule?.source?.filters?.firstOrNull()?.requestMethod?.firstOrNull() ?: httpVerbs[0] + val selectionPosition = httpVerbs.indexOf(selectedMethod) + + val dialog = Dialog(requireContext()) + with(dialog) { + this.setContentView(R.layout.api_modifier_new_mock_rule_dialog) + this.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + this.setCancelable(false) + + val saveButton = dialog.findViewById(R.id.saveTextButton) + val cancelButton = dialog.findViewById(R.id.cancelTextButton) + val httpMethodSpinner = dialog.findViewById(R.id.httpMethodSpinner) + val httpSpinneradaptor = + ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, httpVerbs) + httpSpinneradaptor.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + httpMethodSpinner.adapter = httpSpinneradaptor + httpMethodSpinner.setSelection(selectionPosition) + httpMethodSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(parent: AdapterView<*>?) { + + } + + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long + ) { + selectedMethod = httpVerbs[position] + } + } + + val urlOperatorSpinner = dialog.findViewById(R.id.urlOperatorSpinner) + val urlOperatorSpinnerAdaptor = + ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, operators) + urlOperatorSpinnerAdaptor.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + urlOperatorSpinner.adapter = urlOperatorSpinnerAdaptor + urlOperatorSpinner.setSelection(0) + + val urlSourceEditText = dialog.findViewById(R.id.sourceUrlTargetingEditText) + urlSourceEditText.setText(rule?.source?.value, TextView.BufferType.EDITABLE) + val destinationUrlEditText = dialog.findViewById(R.id.mockUrlInputEditText) + destinationUrlEditText.setText(rule?.destination, TextView.BufferType.EDITABLE) + + saveButton.setOnClickListener { + onSaveClick( + Triple( + selectedMethod, + urlSourceEditText.text.toString(), + destinationUrlEditText.text.toString() + ) + ) + dialog.hide() + } + cancelButton.setOnClickListener { + dialog.hide() + } + } + dialog.show() + } +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragmentViewModel.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragmentViewModel.kt new file mode 100644 index 0000000..182f372 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierFragmentViewModel.kt @@ -0,0 +1,96 @@ +package io.requestly.android.core.modules.apiModifier + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.google.gson.reflect.TypeToken +import io.requestly.android.core.KeyValueStorageManager +import io.requestly.android.core.modules.apiModifier.dao.* + + +typealias SwitchingRule = Rule + +class ApiModifierFragmentViewModel: ViewModel() { + + companion object { + const val KEY_NAME = "io.requestly.api_modifier_rules_key" + } + + private var _rulesListLive = MutableLiveData>() + val rulesListLive: LiveData> = _rulesListLive + + init { + // T should be known at compile time. + // https://stackoverflow.com/a/14506181 + val typeToken = object : TypeToken>() {} + + _rulesListLive.value = KeyValueStorageManager.getList(KEY_NAME, typeToken) ?: emptyList() + } + + fun createReplaceRule(startingText: String, provisionalText: String) { + val newRule = SwitchingRule.newReplaceRule( + from = startingText, + to = provisionalText + ) + val list = _rulesListLive.value!! + newRule + _rulesListLive.value = list + + KeyValueStorageManager.putList(KEY_NAME, list) + } + + fun createRedirectRule(httpMethod: HttpVerb, sourceUrlText: String, destinationUrl: String) { + val newRule = SwitchingRule.newRedirectRule( + destination = destinationUrl, + requestMethod = listOf(httpMethod), + sourceUrlText = sourceUrlText + ) + val list = _rulesListLive.value!! + newRule + _rulesListLive.value = list + + KeyValueStorageManager.putList(KEY_NAME, list) + } + + fun editSwitchState(ruleId: String, newValue: Boolean) { + _rulesListLive.value?.find { it.id === ruleId }?.isActive = newValue + KeyValueStorageManager.putList(KEY_NAME, _rulesListLive.value!!) + } + + fun editReplaceRule(sourceText: String, targetText: String, ruleId: String) { + val mutableList = _rulesListLive.value!!.toMutableList() + val index = mutableList.indexOfFirst { it.id == ruleId } + + if (index != -1) { + val rule = mutableList[index] + mutableList[index] = SwitchingRule.newReplaceRule( + id = rule.id, + from = sourceText, + to = targetText + ) + } + _rulesListLive.postValue(mutableList) + KeyValueStorageManager.putList(KEY_NAME, mutableList) + } + + fun editRedirectRule(httpVerb: HttpVerb, sourceText: String, targetText: String, ruleId: String) { + val mutableList = _rulesListLive.value!!.toMutableList() + val index = mutableList.indexOfFirst { it.id == ruleId } + + if (index != -1) { + val rule = mutableList[index] + mutableList[index] = SwitchingRule.newRedirectRule( + id = rule.id, + destination = targetText, + requestMethod = listOf(httpVerb), + sourceUrlText = sourceText + ) + } + _rulesListLive.postValue(mutableList) + KeyValueStorageManager.putList(KEY_NAME, mutableList) + } + + fun deleteItem(ruleId: String) { + val newList = _rulesListLive.value?.filter { it.id !== ruleId } ?: emptyList() + _rulesListLive.value = newList + KeyValueStorageManager.putList(KEY_NAME, newList) + } +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierRuleItemAdaptor.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierRuleItemAdaptor.kt new file mode 100644 index 0000000..e2d4a3f --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/ApiModifierRuleItemAdaptor.kt @@ -0,0 +1,85 @@ +package io.requestly.android.core.modules.apiModifier + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import io.requestly.android.core.databinding.ApiModifierMockRuleItemBinding + +data class ApiModifierRuleItemModel( + val ruleTypeText: String, + val httpVerbText: String?, + val operatorText: String, + val sourceUrlText: String, + val targetUrlText: String, + val targetUrlGuideText: String, + val isActive: Boolean, + val onSwitchStateChangeListener: ((Boolean) -> Unit)?, + val onEditClickListener: (() -> Unit)?, + val onDeleteClickListener: (() -> Unit)? +) + +class ApiModifierRuleItemAdaptor(var items: List) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding = ApiModifierMockRuleItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ItemViewHolder(binding) + } + + override fun getItemCount(): Int { + return items.size + } + + class ItemViewHolder(itemView: ApiModifierMockRuleItemBinding) : + RecyclerView.ViewHolder(itemView.root) { + + private val ruleTypeTextView = itemView.ruleTypeTextView + private val httpMethodTextView = itemView.httpMethodTextView + private val operatorTextView = itemView.operatorTextView + private val sourceUrlTextView = itemView.sourceUrlTextView + private val targetUrlTextView = itemView.targetUrlTextView + private val targetUrlGuideTextView = itemView.targetUrlGuideText + private val activeSwitch = itemView.activeSwitch + private val editTextButton = itemView.editTextButton + private val deleteTextButton = itemView.deleteTextButton + + fun bindTo(model: ApiModifierRuleItemModel) { + if (model.httpVerbText != null) { + httpMethodTextView.text = model.httpVerbText + httpMethodTextView.visibility = View.VISIBLE + } else { + httpMethodTextView.text = null + httpMethodTextView.visibility = View.GONE + } + + ruleTypeTextView.text = model.ruleTypeText + operatorTextView.text = model.operatorText + sourceUrlTextView.setText(model.sourceUrlText, TextView.BufferType.NORMAL) + targetUrlTextView.setText(model.targetUrlText, TextView.BufferType.NORMAL) + targetUrlGuideTextView.text = model.targetUrlGuideText + activeSwitch.isChecked = model.isActive + activeSwitch.setOnCheckedChangeListener { _: CompoundButton, b: Boolean -> + model.onSwitchStateChangeListener?.let { it(b) } + } + editTextButton.setOnClickListener { + model.onEditClickListener?.let { it1 -> it1() } + } + deleteTextButton.setOnClickListener { + model.onDeleteClickListener?.let { it1 -> it1() } + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + if (holder is ItemViewHolder) { + holder.bindTo(items[position]) + } + } +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleExtensions.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleExtensions.kt new file mode 100644 index 0000000..2e75159 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleExtensions.kt @@ -0,0 +1,106 @@ +package io.requestly.android.core.modules.apiModifier.dao + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type +import java.util.* + +class RuleJsonDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Rule? { + val jsonObject = json?.asJsonObject + + val id = + context?.deserialize(jsonObject?.get("id"), String::class.java) ?: return null + val ruleType = + context.deserialize(jsonObject?.get("ruleType"), RuleType::class.java) + ?: return null + val status = + context.deserialize(jsonObject?.get("status"), RuleStatus::class.java) + ?: RuleStatus.INACTIVE + + val pairsJsonArray = jsonObject?.get("pairs")?.asJsonArray ?: return null + + val pairs = when (ruleType) { + RuleType.REPLACE -> { + pairsJsonArray.map { context.deserialize(it, Replace::class.java) } + } + RuleType.REDIRECT -> { + pairsJsonArray.map { context.deserialize(it, Redirect::class.java) } + } + } + return Rule( + id = id, + ruleType = ruleType, + status = status, + pairs = pairs + ) + } +} + +fun Rule.Companion.newRedirectRule( + id: String = UUID.randomUUID().toString(), + destination: String, + requestMethod: List, + sourceUrlText: String +): Rule { + return Rule( + id = id, + ruleType = RuleType.REDIRECT, + status = RuleStatus.ACTIVE, + pairs = listOf( + Redirect( + destination = destination, + source = Source( + key = SourceKey.URL, + operator = SourceOperator.CONTAINS, + value = sourceUrlText, + filters = listOf( + Filter( + requestMethod + ) + ) + ) + ) + ) + ) +} + +fun Rule.Companion.newReplaceRule( + id: String = UUID.randomUUID().toString(), + from: String, + to: String +): Rule { + return Rule( + id = id, + ruleType = RuleType.REPLACE, + status = RuleStatus.ACTIVE, + pairs = listOf( + Replace( + from, + to, + Source( + key = SourceKey.URL, + operator = SourceOperator.CONTAINS, + value = from, + filters = listOf(Filter(listOf(HttpVerb.GET, HttpVerb.POST))) + ) + ) + ) + ) +} + +var Rule.isActive: Boolean + get() = status == RuleStatus.ACTIVE + set(value) { + if (value) { + status = RuleStatus.ACTIVE + return + } + + status = RuleStatus.INACTIVE + } diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleSchema.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleSchema.kt new file mode 100644 index 0000000..9605aea --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/dao/RuleSchema.kt @@ -0,0 +1,72 @@ +package io.requestly.android.core.modules.apiModifier.dao + +import com.google.gson.annotations.SerializedName + +data class Rule constructor( + val id: String, + val ruleType: RuleType, + internal var status: RuleStatus, + val pairs: List +) { + companion object +} + +enum class SourceKey { + @SerializedName("Url") + URL +} + +enum class SourceOperator { + @SerializedName("Contains") + CONTAINS +} + +enum class HttpVerb { + GET, + POST, + PUT, + PATCH, + DELETE, + OPTIONS, + CONNECT, + TRACE +} + +data class Filter( + @SerializedName("requestMethod") val requestMethod: List +) + +data class Source( + @SerializedName("key") val key: SourceKey, + @SerializedName("operator") val operator: SourceOperator, + @SerializedName("value") val value: String, + @SerializedName("filters") val filters: List = emptyList() +) + +sealed class Pair +class Redirect( + @SerializedName("destination") val destination: String, + @SerializedName("source") val source: Source +) : Pair() + +class Replace( + @SerializedName("from") val from: String, + @SerializedName("to") val to: String, + @SerializedName("source") val source: Source +) : Pair() + +enum class RuleType { + @SerializedName("Replace") + REPLACE, + + @SerializedName("Redirect") + REDIRECT +} + +enum class RuleStatus { + @SerializedName("Active") + ACTIVE, + + @SerializedName("Inactive") + INACTIVE +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/ActionProcessor.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/ActionProcessor.kt new file mode 100644 index 0000000..f038fb6 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/ActionProcessor.kt @@ -0,0 +1,22 @@ +package io.requestly.android.core.modules.apiModifier.processors + +import io.requestly.android.core.modules.apiModifier.processors.models.Action +import io.requestly.android.core.modules.apiModifier.processors.models.ActionName +import okhttp3.Request + +object ActionProcessor { + + fun process(actions: List, request: Request): Request { + if (actions.isEmpty()) { + return request + } + + val firstAction = actions.first() + + return when (firstAction.action) { + ActionName.REDIRECT -> { + request.newBuilder().url(firstAction.url).build() + } + } + } +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/RuleProcessor.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/RuleProcessor.kt new file mode 100644 index 0000000..2f25f31 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/RuleProcessor.kt @@ -0,0 +1,77 @@ +package io.requestly.android.core.modules.apiModifier.processors + +import android.util.Patterns +import com.google.gson.reflect.TypeToken +import io.requestly.android.core.KeyValueStorageManager +import io.requestly.android.core.modules.apiModifier.ApiModifierFragmentViewModel +import io.requestly.android.core.modules.apiModifier.dao.* +import io.requestly.android.core.modules.apiModifier.processors.models.Action +import io.requestly.android.core.modules.apiModifier.processors.models.ActionName +import okhttp3.Request +import java.net.URL + +object RuleProcessor { + + private var activeRules: MutableList = mutableListOf() + + init { + val typeToken = object : TypeToken>() {} + val storageChangeListener: () -> Unit = { + activeRules = + KeyValueStorageManager.getList(ApiModifierFragmentViewModel.KEY_NAME, typeToken) + ?.filter { + it.isActive + }?.toMutableList() ?: mutableListOf() + } + storageChangeListener() + KeyValueStorageManager.registerChangeListener( + ApiModifierFragmentViewModel.KEY_NAME, + storageChangeListener + ) + } + + fun process(request: Request): List { + val actions = activeRules.map mapperFunc@{ + val rule = it.pairs.firstOrNull() ?: return@mapperFunc null + when (rule) { + is Redirect -> redirectRuleProcessor(rule, request) + is Replace -> replaceRuleProcessor(rule, request) + } + } + return actions.filterNotNull() + } +} + +private fun replaceRuleProcessor(rule: Replace, request: Request): Action? { + val url = request.url().toString() + if (!url.contains(rule.from)) { + return null + } + val modifiedUrl = url.replace(rule.from, rule.to, true) + if (!Patterns.WEB_URL.matcher(url).matches()) return null + + return Action( + action = ActionName.REDIRECT, + url = URL(modifiedUrl) + ) +} + +private fun redirectRuleProcessor(rule: Redirect, request: Request): Action? { + val url = request.url().toString() + if (!url.contains(rule.source.value)) { + return null + } + + rule.source.filters + .firstOrNull()?.requestMethod + ?.firstOrNull { it.toString() == request.method() } + ?: return null + + if (!Patterns.WEB_URL.matcher(rule.destination).matches()) return null + + return Action( + action = ActionName.REDIRECT, + url = URL(rule.destination) + ) +} + diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/models/Action.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/models/Action.kt new file mode 100644 index 0000000..0177096 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/apiModifier/processors/models/Action.kt @@ -0,0 +1,11 @@ +package io.requestly.android.core.modules.apiModifier.processors.models + +import java.net.URL + +enum class ActionName { + REDIRECT +} +data class Action( + val action: ActionName, + val url: URL +) diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/commonUtil.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/commonUtil.kt new file mode 100644 index 0000000..39517e8 --- /dev/null +++ b/requestly-android-core/src/main/java/io/requestly/android/core/modules/commonUtil.kt @@ -0,0 +1,25 @@ +package io.requestly.android.core.modules + +import android.app.AlertDialog +import android.content.Context + +internal fun loadSimpleYesNoAlertDialog( + context: Context, + message: String, + onPositiveButtonClick: () -> Unit, + onNegativeButtonClick: (() -> Unit)? = null, +) { + AlertDialog.Builder(context) + .setCancelable(false) + .setTitle(message) + .setPositiveButton("Yes") { dialog, _ -> + onPositiveButtonClick() + dialog.cancel() + } + .setNegativeButton("No") { dialog, _ -> + onNegativeButtonClick?.let { it() } + dialog.cancel() + } + .create() + .show() +} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitchItemAdaptor.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitchItemAdaptor.kt deleted file mode 100644 index 0431939..0000000 --- a/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitchItemAdaptor.kt +++ /dev/null @@ -1,59 +0,0 @@ -package io.requestly.android.core.modules.hostSwitcher - -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.CompoundButton -import androidx.recyclerview.widget.RecyclerView -import io.requestly.android.core.databinding.HostSwitcherItemBinding - -data class HostSwitchItemModel( - val startingText: String, - val provisionalText: String, - val isActive: Boolean, - val onSwitchStateChangeListener: ((Boolean) -> Unit)?, - val onEditClickListener: (() -> Unit)?, - val onDeleteClickListener: (() -> Unit)? -) - -class HostSwitchItemAdaptor(var items: List) : - RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { - val viewBinding = - HostSwitcherItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ItemViewHolder(viewBinding) - } - - override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { - holder.bindTo(items[position]) - } - - override fun getItemCount(): Int { - return items.size - } - - class ItemViewHolder(itemView: HostSwitcherItemBinding) : - RecyclerView.ViewHolder(itemView.root) { - - private val startingTextView = itemView.startingTextView - private val provisionalTextView = itemView.provisionalTextView - private val editButton = itemView.editButton - private val deleteButton = itemView.deleteButton - private val activeSwitch = itemView.activeSwitch - - fun bindTo(model: HostSwitchItemModel) { - startingTextView.text = model.startingText - provisionalTextView.text = model.provisionalText - activeSwitch.isChecked = model.isActive - activeSwitch.setOnCheckedChangeListener { _: CompoundButton, b: Boolean -> - model.onSwitchStateChangeListener?.let { it(b) } - } - editButton.setOnClickListener { - model.onEditClickListener?.let { it1 -> it1() } - } - deleteButton.setOnClickListener { - model.onDeleteClickListener?.let { it1 -> it1() } - } - } - } -} diff --git a/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitcherFragment.kt b/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitcherFragment.kt deleted file mode 100644 index 4c7aa28..0000000 --- a/requestly-android-core/src/main/java/io/requestly/android/core/modules/hostSwitcher/HostSwitcherFragment.kt +++ /dev/null @@ -1,138 +0,0 @@ -package io.requestly.android.core.modules.hostSwitcher - -import android.app.AlertDialog -import android.app.Dialog -import android.os.Bundle -import android.view.* -import android.widget.Button -import android.widget.EditText -import android.widget.TextView -import androidx.fragment.app.Fragment -import androidx.core.view.MenuProvider -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import io.requestly.android.core.R -import io.requestly.android.core.databinding.FragmentHostSwitcherBinding -typealias OnSaveClickFnType = (startingText: String, provisionalText: String) -> Unit -class HostSwitcherFragment : Fragment() { - - private lateinit var mainBinding: FragmentHostSwitcherBinding - private val viewModel: HostSwitcherFragmentViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - mainBinding = FragmentHostSwitcherBinding.inflate(layoutInflater) - - // Inflate the layout for this fragment - initRecyclerView() - - return mainBinding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val provider = object : MenuProvider { - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.host_switcher_fragment_menu, menu) - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.addNew -> { - loadAddNewHostSwitchItemDialog(null) { t1, t2 -> - viewModel.createItem(startingText = t1, provisionalText = t2) - } - true - } - else -> { - false - } - } - } - } - requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED) - } - - private fun initRecyclerView() { - mainBinding.hostSwitcherRulesRecyclerView.layoutManager = - LinearLayoutManager(requireContext()) - val mapper: (SwitchingRule) -> HostSwitchItemModel = { - HostSwitchItemModel( - startingText = it.startingText, - provisionalText = it.provisionalText, - isActive = it.isActive, - onSwitchStateChangeListener = { boolValue -> - viewModel.editSwitchState( - it.id, - boolValue - ) - }, - onEditClickListener = { - loadAddNewHostSwitchItemDialog(it) { t1, t2 -> - viewModel.editItem(t1, t2, it.id) - } - }, - onDeleteClickListener = { - loadDeleteConfirmationDialog { - viewModel.deleteItem(it.id) - } - } - ) - } - val items: List = - viewModel.rulesListLive.value?.map(mapper) ?: emptyList() - val adaptor = HostSwitchItemAdaptor(items) - viewModel.rulesListLive.observe(viewLifecycleOwner) { - adaptor.items = it.map(mapper) - adaptor.notifyDataSetChanged() - } - mainBinding.hostSwitcherRulesRecyclerView.adapter = adaptor - } - - private fun loadAddNewHostSwitchItemDialog( - itemRule: SwitchingRule?, - onSaveClick: OnSaveClickFnType - ) { - val dialog = Dialog(requireContext()) - dialog.setContentView(R.layout.host_switcher_new_item_dialog) - dialog.window?.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - dialog.setCancelable(false) - dialog.show() - - val saveButton = dialog.findViewById