Merge pull request #90 from kirmanak/allow-server-change

Allow changing server URL through UI
This commit is contained in:
Kirill Kamakin
2022-11-12 13:28:09 +01:00
committed by GitHub
8 changed files with 150 additions and 48 deletions

View File

@@ -5,14 +5,16 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.observeOnce import gq.kirmanak.mealient.extensions.observeOnce
@@ -20,9 +22,9 @@ import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity(R.layout.main_activity) {
private lateinit var binding: MainActivityBinding private val binding: MainActivityBinding by viewBinding(MainActivityBinding::bind, R.id.drawer)
private val viewModel by viewModels<MainActivityViewModel>() private val viewModel by viewModels<MainActivityViewModel>()
private val title: String by lazy { getString(R.string.app_name) } private val title: String by lazy { getString(R.string.app_name) }
private val uiState: MainActivityUiState get() = viewModel.uiState private val uiState: MainActivityUiState get() = viewModel.uiState
@@ -37,7 +39,6 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" } logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null } splashScreen.setKeepOnScreenCondition { viewModel.startDestination.value == null }
binding = MainActivityBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
configureToolbar() configureToolbar()
configureNavGraph() configureNavGraph()
@@ -64,12 +65,13 @@ class MainActivity : AppCompatActivity() {
private fun onNavigationItemSelected(menuItem: MenuItem): Boolean { private fun onNavigationItemSelected(menuItem: MenuItem): Boolean {
logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" } logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" }
menuItem.isChecked = true menuItem.isChecked = true
val deepLink = when (menuItem.itemId) { val directions = when (menuItem.itemId) {
R.id.add_recipe -> ADD_RECIPE_DEEP_LINK R.id.add_recipe -> NavGraphDirections.actionGlobalAddRecipeFragment()
R.id.recipes_list -> RECIPES_LIST_DEEP_LINK R.id.recipes_list -> NavGraphDirections.actionGlobalRecipesFragment()
R.id.change_url -> NavGraphDirections.actionGlobalBaseURLFragment()
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}") else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
} }
navigateDeepLink(deepLink) navigateTo(directions)
binding.drawer.close() binding.drawer.close()
return true return true
} }
@@ -109,7 +111,7 @@ class MainActivity : AppCompatActivity() {
logger.v { "onOptionsItemSelected() called with: item = $item" } logger.v { "onOptionsItemSelected() called with: item = $item" }
val result = when (item.itemId) { val result = when (item.itemId) {
R.id.login -> { R.id.login -> {
navigateDeepLink(AUTH_DEEP_LINK) navigateTo(NavGraphDirections.actionGlobalAuthenticationFragment())
true true
} }
R.id.logout -> { R.id.logout -> {
@@ -121,14 +123,8 @@ class MainActivity : AppCompatActivity() {
return result return result
} }
private fun navigateDeepLink(deepLink: String) { private fun navigateTo(directions: NavDirections) {
logger.v { "navigateDeepLink() called with: deepLink = $deepLink" } logger.v { "navigateTo() called with: directions = $directions" }
navController.navigate(deepLink.toUri()) navController.navigate(directions)
}
companion object {
private const val AUTH_DEEP_LINK = "mealient://authenticate"
private const val ADD_RECIPE_DEEP_LINK = "mealient://recipe/add"
private const val RECIPES_LIST_DEEP_LINK = "mealient://recipe/list"
} }
} }

View File

@@ -5,8 +5,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState import gq.kirmanak.mealient.ui.OperationUiState
@@ -16,6 +18,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BaseURLViewModel @Inject constructor( class BaseURLViewModel @Inject constructor(
private val serverInfoRepo: ServerInfoRepo, private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo,
private val versionDataSource: VersionDataSource, private val versionDataSource: VersionDataSource,
private val logger: Logger, private val logger: Logger,
) : ViewModel() { ) : ViewModel() {
@@ -33,10 +37,17 @@ class BaseURLViewModel @Inject constructor(
private suspend fun checkBaseURL(baseURL: String) { private suspend fun checkBaseURL(baseURL: String) {
logger.v { "checkBaseURL() called with: baseURL = $baseURL" } logger.v { "checkBaseURL() called with: baseURL = $baseURL" }
if (baseURL == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" }
_uiState.value = OperationUiState.fromResult(Result.success(Unit))
return
}
val result = runCatchingExceptCancel { val result = runCatchingExceptCancel {
// If it returns proper version info then it must be a Mealie // If it returns proper version info then it must be a Mealie
val version = versionDataSource.getVersionInfo(baseURL).version val version = versionDataSource.getVersionInfo(baseURL).version
serverInfoRepo.storeBaseURL(baseURL, version) serverInfoRepo.storeBaseURL(baseURL, version)
authRepo.logout()
recipeRepo.clearLocalData()
} }
logger.i { "checkBaseURL: result is $result" } logger.i { "checkBaseURL: result is $result" }
_uiState.value = OperationUiState.fromResult(result) _uiState.value = OperationUiState.fromResult(result)

View File

@@ -7,4 +7,8 @@
<item <item
android:id="@+id/add_recipe" android:id="@+id/add_recipe"
android:title="@string/menu_bottom_navigation_add_recipe" /> android:title="@string/menu_bottom_navigation_add_recipe" />
<item
android:id="@+id/change_url"
android:title="@string/menu_bottom_navigation_change_url" />
</menu> </menu>

View File

@@ -9,11 +9,8 @@
android:id="@+id/authenticationFragment" android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment" android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment" android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication"> tools:layout="@layout/fragment_authentication" />
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://authenticate" />
</fragment>
<fragment <fragment
android:id="@+id/recipesFragment" android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment" android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
@@ -22,10 +19,8 @@
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" /> app:destination="@id/recipeInfoFragment" />
<deepLink
android:id="@+id/deepLink"
app:uri="mealient://recipe/list" />
</fragment> </fragment>
<dialog <dialog
android:id="@+id/recipeInfoFragment" android:id="@+id/recipeInfoFragment"
android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment" android:name="gq.kirmanak.mealient.ui.recipes.info.RecipeInfoFragment"
@@ -35,6 +30,7 @@
android:name="recipe_id" android:name="recipe_id"
app:argType="string" /> app:argType="string" />
</dialog> </dialog>
<fragment <fragment
android:id="@+id/disclaimerFragment" android:id="@+id/disclaimerFragment"
android:name="gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment" android:name="gq.kirmanak.mealient.ui.disclaimer.DisclaimerFragment"
@@ -46,6 +42,7 @@
app:popUpTo="@id/nav_graph" app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/baseURLFragment" android:id="@+id/baseURLFragment"
android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment" android:name="gq.kirmanak.mealient.ui.baseurl.BaseURLFragment"
@@ -57,13 +54,26 @@
app:popUpTo="@id/nav_graph" app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" /> app:popUpToInclusive="true" />
</fragment> </fragment>
<fragment <fragment
android:id="@+id/addRecipeFragment" android:id="@+id/addRecipeFragment"
android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment" android:name="gq.kirmanak.mealient.ui.add.AddRecipeFragment"
android:label="fragment_add_recipe" android:label="fragment_add_recipe"
tools:layout="@layout/fragment_add_recipe"> tools:layout="@layout/fragment_add_recipe" />
<deepLink
android:id="@+id/deepLink" <action
app:uri="mealient://recipe/add" /> android:id="@+id/action_global_authenticationFragment"
</fragment> app:destination="@id/authenticationFragment" />
<action
android:id="@+id/action_global_recipesFragment"
app:destination="@id/recipesFragment" />
<action
android:id="@+id/action_global_addRecipeFragment"
app:destination="@id/addRecipeFragment" />
<action
android:id="@+id/action_global_baseURLFragment"
app:destination="@id/baseURLFragment" />
</navigation> </navigation>

View File

@@ -47,4 +47,5 @@
<string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string> <string name="fragment_recipes_load_failure_toast_unexpected_response">неожиданный ответ</string>
<string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string> <string name="fragment_recipes_load_failure_toast_no_connection">нет соединения</string>
<string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string> <string name="fragment_recipes_load_failure_toast_no_reason">Ошибка загрузки.</string>
<string name="menu_bottom_navigation_change_url">Сменить URL</string>
</resources> </resources>

View File

@@ -51,4 +51,5 @@
<string name="fragment_recipes_load_failure_toast_unauthorized">unauthorized</string> <string name="fragment_recipes_load_failure_toast_unauthorized">unauthorized</string>
<string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string> <string name="fragment_recipes_load_failure_toast_unexpected_response">unexpected response</string>
<string name="fragment_recipes_load_failure_toast_no_connection">no connection</string> <string name="fragment_recipes_load_failure_toast_no_connection">no connection</string>
<string name="menu_bottom_navigation_change_url">Change URL</string>
</resources> </resources>

View File

@@ -1,10 +0,0 @@
package gq.kirmanak.mealient.test
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(application = Application::class, manifest = Config.NONE)
abstract class RobolectricTest

View File

@@ -1,32 +1,48 @@
package gq.kirmanak.mealient.ui.baseurl package gq.kirmanak.mealient.ui.baseurl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.VersionDataSource import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
import gq.kirmanak.mealient.test.FakeLogger import gq.kirmanak.mealient.test.FakeLogger
import gq.kirmanak.mealient.test.RobolectricTest import gq.kirmanak.mealient.ui.OperationUiState
import io.mockk.MockKAnnotations import io.mockk.MockKAnnotations
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.*
import kotlinx.coroutines.test.runTest import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : RobolectricTest() { class BaseURLViewModelTest {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var recipeRepo: RecipeRepo
@MockK @MockK
lateinit var versionDataSource: VersionDataSource lateinit var versionDataSource: VersionDataSource
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
private val logger: Logger = FakeLogger() private val logger: Logger = FakeLogger()
lateinit var subject: BaseURLViewModel lateinit var subject: BaseURLViewModel
@@ -34,16 +50,89 @@ class BaseURLViewModelTest : RobolectricTest() {
@Before @Before
fun setUp() { fun setUp() {
MockKAnnotations.init(this) MockKAnnotations.init(this)
subject = BaseURLViewModel(serverInfoRepo, versionDataSource, logger) Dispatchers.setMain(UnconfinedTestDispatcher())
subject = BaseURLViewModel(
serverInfoRepo = serverInfoRepo,
authRepo = authRepo,
recipeRepo = recipeRepo,
versionDataSource = versionDataSource,
logger = logger,
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
} }
@Test @Test
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest { fun `when saveBaseURL expect no version checks given that current URL matches new`() = runTest {
setupSaveBaseUrlWithOldUrl()
coVerify(inverse = true) { versionDataSource.getVersionInfo(any()) }
}
@Test
fun `when saveBaseURL expect URL isn't saved given that current URL matches new`() = runTest {
setupSaveBaseUrlWithOldUrl()
coVerify(inverse = true) { serverInfoRepo.storeBaseURL(any(), any()) }
}
@Test
fun `when saveBaseURL expect no logout given that current URL matches new`() = runTest {
setupSaveBaseUrlWithOldUrl()
coVerify(inverse = true) { authRepo.logout() }
}
@Test
fun `when saveBaseURL expect data intact given that current URL matches new`() = runTest {
setupSaveBaseUrlWithOldUrl()
coVerify(inverse = true) { recipeRepo.clearLocalData() }
}
private fun TestScope.setupSaveBaseUrlWithOldUrl() {
coEvery { serverInfoRepo.getUrl() } returns TEST_BASE_URL
versionDataSourceReturnsSuccess()
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
}
@Test
fun `when saveBaseUrl expect URL is saved given that new URL doesn't match old`() = runTest {
setupSaveBaseUrlWithNewUrl()
coVerify { serverInfoRepo.storeBaseURL(eq(TEST_BASE_URL), eq(TEST_VERSION)) }
}
@Test
fun `when saveBaseURL expect logout given that new URL doesn't match old`() = runTest {
setupSaveBaseUrlWithNewUrl()
coVerify { authRepo.logout() }
}
@Test
fun `when saveBaseURL expect recipes removed given that new URL doesn't match old`() = runTest {
setupSaveBaseUrlWithNewUrl()
coVerify { recipeRepo.clearLocalData() }
}
private fun TestScope.setupSaveBaseUrlWithNewUrl() {
coEvery { serverInfoRepo.getUrl() } returns null
versionDataSourceReturnsSuccess()
subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle()
}
private fun versionDataSourceReturnsSuccess() {
coEvery { coEvery {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL)) versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} returns VersionInfo(TEST_VERSION) } returns VersionInfo(TEST_VERSION)
}
@Test
fun `when saveBaseURL expect error given that version can't be fetched`() = runTest {
coEvery { serverInfoRepo.getUrl() } returns null
coEvery { versionDataSource.getVersionInfo(eq(TEST_BASE_URL)) } throws IOException()
subject.saveBaseUrl(TEST_BASE_URL) subject.saveBaseUrl(TEST_BASE_URL)
advanceUntilIdle() advanceUntilIdle()
coVerify { serverInfoRepo.storeBaseURL(eq(TEST_BASE_URL), eq(TEST_VERSION)) } assertThat(subject.uiState.value).isInstanceOf(OperationUiState.Failure::class.java)
} }
} }