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:
Ray 2026-03-03 13:46:46 -05:00 committed by GitHub
parent 8935067c5a
commit 0004701296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 990 additions and 163 deletions

View file

@ -44,7 +44,7 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = gitTags.trim().lines().size versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
} }
signingConfigs { signingConfigs {
@ -140,6 +140,12 @@ android {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
} }
} }
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
protobuf { protobuf {
@ -254,6 +260,7 @@ dependencies {
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx) implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect) implementation(libs.androidx.media3.effect)
implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
@ -292,4 +299,9 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) 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)
} }

View file

@ -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)
}

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -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)
}

View 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>

View file

@ -8,12 +8,9 @@ import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope 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.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService 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.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO 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.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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -230,129 +211,19 @@ class MainActivity : AppCompatActivity() {
true, true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors, 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 = val requestedDestination =
remember(intent) { remember(intent) {
intent?.let(::extractDestination) intent?.let(::extractDestination) ?: Destination.Home()
} }
ApplicationContent( MainContent(
user = current.user, backStack = setupNavigationManager.backStack,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(), 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())
}
}
}
} }
} }
} }
@ -400,6 +271,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
Timber.d("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) { override fun onSaveInstanceState(outState: Bundle) {

View 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())
}
}
}
}

View file

@ -9,10 +9,12 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences 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.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -41,6 +43,7 @@ class ServerRepository
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
private var _current = EqualityMutableLiveData<CurrentUser?>(null) private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: LiveData<CurrentUser?> = _current
@ -57,7 +60,7 @@ class ServerRepository
* The current user is removed * The current user is removed
*/ */
suspend fun addAndChangeServer(server: JellyfinServer) { suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server) serverDao.addOrUpdateServer(server)
} }
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
@ -71,7 +74,7 @@ class ServerRepository
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
): CurrentUser? = ): CurrentUser? =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
if (server.id != user.serverId) { if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
} }
@ -126,7 +129,7 @@ class ServerRepository
return null return null
} }
val serverAndUsers = val serverAndUsers =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.getServer(serverId) serverDao.getServer(serverId)
} }
if (serverAndUsers != null) { if (serverAndUsers != null) {
@ -149,7 +152,7 @@ class ServerRepository
} }
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server } serverId?.let { serverDao.getServer(serverId)?.server }
} }
@ -163,7 +166,7 @@ class ServerRepository
suspend fun changeUser( suspend fun changeUser(
serverUrl: String, serverUrl: String,
authenticationResult: AuthenticationResult, authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) { ) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken val accessToken = authenticationResult.accessToken
if (accessToken != null) { if (accessToken != null) {
val authedUser = authenticationResult.user val authedUser = authenticationResult.user
@ -213,7 +216,7 @@ class ServerRepository
} }
apiClient.update(accessToken = null) apiClient.update(accessToken = null)
} }
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id) serverDao.deleteUser(user.serverId, user.id)
} }
} }
@ -233,7 +236,7 @@ class ServerRepository
} }
apiClient.update(baseUrl = null, accessToken = null) apiClient.update(baseUrl = null, accessToken = null)
} }
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.deleteServer(server.id) serverDao.deleteServer(server.id)
} }
} }
@ -252,7 +255,7 @@ class ServerRepository
suspend fun setUserPin( suspend fun setUserPin(
user: JellyfinUser, user: JellyfinUser,
pin: String?, pin: String?,
) = withContext(Dispatchers.IO) { ) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin) val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser) val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@ -265,7 +268,7 @@ class ServerRepository
} }
suspend fun authorizeQuickConnect(code: String): Boolean = suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
val userId = currentUser.value?.id val userId = currentUser.value?.id
if (userId == null) { if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization") Timber.e("No user logged in for Quick Connect authorization")

View file

@ -17,7 +17,7 @@ import javax.inject.Singleton
class SetupNavigationManager class SetupNavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/** /**
* Go to the specified [SetupDestination] * Go to the specified [SetupDestination]

View file

@ -18,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -49,6 +50,14 @@ annotation class IoCoroutineScope
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope annotation class DefaultCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@ -62,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME, version = BuildConfig.VERSION_NAME,
) )
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
@StandardOkHttpClient @StandardOkHttpClient
@Provides @Provides
@Singleton @Singleton
@ -177,15 +180,29 @@ object AppModule {
} }
} }
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides @Provides
@Singleton @Singleton
@IoCoroutineScope @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 @Provides
@Singleton @Singleton
@DefaultCoroutineScope @DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides @Provides
@Singleton @Singleton
@ -199,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient) ) = SeerrApi(okHttpClient)
} }
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
}

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -382,7 +383,10 @@ fun AddServerCard(
Surface( Surface(
onClick = onClick, onClick = onClick,
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.size(cardSize), modifier =
Modifier
.size(cardSize)
.testTag("add_server"),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape), shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors = colors =
ClickableSurfaceDefaults.colors( ClickableSurfaceDefaults.colors(

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@ -352,6 +353,7 @@ fun SwitchServerContent(
), ),
modifier = modifier =
Modifier Modifier
.testTag("server_url_text")
.focusRequester(textBoxFocusRequester) .focusRequester(textBoxFocusRequester)
.fillMaxWidth(), .fillMaxWidth(),
) )

View file

@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -42,6 +45,8 @@ class SwitchServerViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: SetupNavigationManager, val navigationManager: SetupNavigationManager,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util
import android.content.Context import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService import com.google.auto.service.AutoService
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -49,7 +50,7 @@ class CrashReportSender : ReportSender {
createJellyfin { createJellyfin {
this.context = context this.context = context
clientInfo = AppModule.clientInfo(context) clientInfo = AppModule.clientInfo(context)
deviceInfo = AppModule.deviceInfo(context) deviceInfo = DeviceModule.deviceInfo(context)
apiClientFactory = okHttpFactory apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion minimumServerVersion = Jellyfin.minimumVersion

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -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()
}
}

View file

@ -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() },
),
)
}
}

View file

@ -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())

View file

@ -43,6 +43,7 @@ paletteKtx = "1.0.0"
kotlinxCoroutinesTest = "1.10.2" kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0" coreTesting = "2.2.0"
openapi-generator = "7.20.0" openapi-generator = "7.20.0"
runner = "1.7.0"
[libraries] [libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } 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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } 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-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } 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" } 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" } 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 = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", 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 = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
kache-file = { module = "com.mayakapps.kache:file-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" } 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" } 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" } 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-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }