Merge pull request #90 from kirmanak/allow-server-change
Allow changing server URL through UI
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user