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

View File

@@ -5,8 +5,10 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.VersionDataSource
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
@@ -16,6 +18,8 @@ import javax.inject.Inject
@HiltViewModel
class BaseURLViewModel @Inject constructor(
private val serverInfoRepo: ServerInfoRepo,
private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo,
private val versionDataSource: VersionDataSource,
private val logger: Logger,
) : ViewModel() {
@@ -33,10 +37,17 @@ class BaseURLViewModel @Inject constructor(
private suspend fun checkBaseURL(baseURL: String) {
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 {
// If it returns proper version info then it must be a Mealie
val version = versionDataSource.getVersionInfo(baseURL).version
serverInfoRepo.storeBaseURL(baseURL, version)
authRepo.logout()
recipeRepo.clearLocalData()
}
logger.i { "checkBaseURL: result is $result" }
_uiState.value = OperationUiState.fromResult(result)

View File

@@ -7,4 +7,8 @@
<item
android:id="@+id/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>

View File

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

View File

@@ -47,4 +47,5 @@
<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_reason">Ошибка загрузки.</string>
<string name="menu_bottom_navigation_change_url">Сменить URL</string>
</resources>

View File

@@ -51,4 +51,5 @@
<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_no_connection">no connection</string>
<string name="menu_bottom_navigation_change_url">Change URL</string>
</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
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.VersionDataSource
import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_VERSION
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.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.io.IOException
@OptIn(ExperimentalCoroutinesApi::class)
class BaseURLViewModelTest : RobolectricTest() {
class BaseURLViewModelTest {
@MockK(relaxUnitFun = true)
lateinit var serverInfoRepo: ServerInfoRepo
@MockK(relaxUnitFun = true)
lateinit var authRepo: AuthRepo
@MockK(relaxUnitFun = true)
lateinit var recipeRepo: RecipeRepo
@MockK
lateinit var versionDataSource: VersionDataSource
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
private val logger: Logger = FakeLogger()
lateinit var subject: BaseURLViewModel
@@ -34,16 +50,89 @@ class BaseURLViewModelTest : RobolectricTest() {
@Before
fun setUp() {
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
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 {
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
} 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)
advanceUntilIdle()
coVerify { serverInfoRepo.storeBaseURL(eq(TEST_BASE_URL), eq(TEST_VERSION)) }
assertThat(subject.uiState.value).isInstanceOf(OperationUiState.Failure::class.java)
}
}