mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Dev: add simple automated UI test setup (#1027)
## Description This PR is mostly a proof-of-concept for setting up automated UI testing. The tests use Roboelectric so they can run without an emulator. There is also a sample instrumented test for running on emulators or devices for future tests. It adds two basic automated UI tests for adding a server. These are sort integration tests because they use the actual implementations for the view model and services like `ServerRepository` and `JellyfinServerDao`. Also, moves the bulk of `MainActivity`'s compose code into a function for future tests. ### Related issues N/A ### Testing Local testing ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
8935067c5a
commit
0004701296
19 changed files with 990 additions and 163 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TestActivity>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TestActivity : ComponentActivity()
|
||||
|
|
@ -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)
|
||||
}
|
||||
94
app/src/debug/AndroidManifest.xml
Normal file
94
app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<!-- Required for Android 11+ to query voice recognition availability -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Wholphin"
|
||||
android:name=".WholphinApplication"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".test.TestActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- <service-->
|
||||
<!-- android:name=".WholphinDreamService"-->
|
||||
<!-- android:exported="true"-->
|
||||
<!-- android:label="@string/app_name"-->
|
||||
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.service.dreams.DreamService" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </service>-->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
|
|
@ -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<SetupDestination>,
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||
val current: LiveData<CurrentUser?> = _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")
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import javax.inject.Singleton
|
|||
class SetupNavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
|
||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||
|
||||
/**
|
||||
* Go to the specified [SetupDestination]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<List<JellyfinServer>>(listOf())
|
||||
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TestActivity : ComponentActivity()
|
||||
|
|
@ -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<TestActivity>()
|
||||
|
||||
@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<QuickConnectApi>()
|
||||
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<QuickConnectApi>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppPreferences>,
|
||||
@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<AppPreferences> =
|
||||
DataStoreFactory.create(
|
||||
serializer = userPreferencesSerializer,
|
||||
produceFile = { context.dataStoreFile("app_preferences.pb") },
|
||||
corruptionHandler =
|
||||
ReplaceFileCorruptionHandler(
|
||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <T> successResponse(content: T) = Response(content, 200, emptyMap())
|
||||
Loading…
Add table
Add a link
Reference in a new issue