diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 366dfcd1..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,7 @@ android { targetSdk = 36 versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" } signingConfigs { @@ -140,6 +140,12 @@ android { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } protobuf { @@ -254,6 +260,7 @@ dependencies { implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -292,4 +299,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..72cf2896 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,75 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + requestedDestination = Destination.Home(), + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..29b2eb75 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 5a6e446f..e2afbfa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -8,12 +8,9 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService @@ -62,17 +47,12 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint @@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -230,129 +211,19 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - LaunchedEffect(Unit) { - backdropService.clearBackdrop() - } - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - ApplicationContent( - user = current.user, - server = current.server, - startDestination = - requestedDestination - ?: Destination.Home(), - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } - } - }, - ) - val screenSaverState by screensaverService.state.collectAsState() - if (screenSaverState.enabled || screenSaverState.enabledTemp) { - AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), - ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) - } + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() } - } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), + ) } } } @@ -400,6 +271,22 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..982240a4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.components.AppScreensaver +import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock + +@Composable +fun MainContent( + backStack: MutableList, + navigationManager: NavigationManager, + appPreferences: AppPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + requestedDestination: Destination, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + ProvideLocalClock { + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 610d0b88..2d786ed8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -9,10 +9,12 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -41,6 +43,7 @@ class ServerRepository val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { private var _current = EqualityMutableLiveData(null) val current: LiveData = _current @@ -57,7 +60,7 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) @@ -71,7 +74,7 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ): CurrentUser? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -126,7 +129,7 @@ class ServerRepository return null } val serverAndUsers = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.getServer(serverId) } if (serverAndUsers != null) { @@ -149,7 +152,7 @@ class ServerRepository } suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverId?.let { serverDao.getServer(serverId)?.server } } @@ -163,7 +166,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -213,7 +216,7 @@ class ServerRepository } apiClient.update(accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } @@ -233,7 +236,7 @@ class ServerRepository } apiClient.update(baseUrl = null, accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } @@ -252,7 +255,7 @@ class ServerRepository suspend fun setUserPin( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val newUser = user.copy(pin = pin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { @@ -265,7 +268,7 @@ class ServerRepository } suspend fun authorizeQuickConnect(code: String): Boolean = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val userId = currentUser.value?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index bd95ea1d..e4910031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class SetupNavigationManager @Inject constructor() { - var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) + var backStack: MutableList = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index e3e42efa..991a8e98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -18,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -49,6 +50,14 @@ annotation class IoCoroutineScope @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -62,12 +71,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -177,15 +180,29 @@ object AppModule { } } + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @Provides @Singleton @@ -199,3 +216,13 @@ object AppModule { @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) } + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index b209ee59..5fd47621 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -382,7 +383,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 4902ca06..a4af8580 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -352,6 +353,7 @@ fun SwitchServerContent( ), modifier = Modifier + .testTag("server_url_text") .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3..08398a86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -42,6 +45,8 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { val servers = MutableLiveData>(listOf()) val serverStatus = MutableLiveData>(mapOf()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 43d3e45a..c1630900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import android.content.Context import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -49,7 +50,7 @@ class CrashReportSender : ReportSender { createJellyfin { this.context = context clientInfo = AppModule.clientInfo(context) - deviceInfo = AppModule.deviceInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory minimumServerVersion = Jellyfin.minimumVersion diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt new file mode 100644 index 00000000..66178a4e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(TestModule.testDispatcher) + mockkStatic(Dispatchers::class) + + every { Dispatchers.IO } returns TestModule.testDispatcher + every { Dispatchers.Default } returns TestModule.testDispatcher + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.addServerState.value.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt new file mode 100644 index 00000000..39705dd3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,282 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.DatabaseModule +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser.value?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ + @Provides + @Singleton + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore, + @IoCoroutineScope scope: CoroutineScope, + ): RememberTabManager = mockk() + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, Dispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + + @Provides + @Singleton + fun userPreferencesDataStore( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt new file mode 100644 index 00000000..d0bc8532 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,16 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.jellyfin.sdk.api.client.Response + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +fun successResponse(content: T) = Response(content, 200, emptyMap()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06a1b888..e477d360 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" +runner = "1.7.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } @@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } @@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }