diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 20dcc6fc0..b899ce6eb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -94,6 +94,25 @@ android { checkReleaseBuilds = false } + testOptions { + managedDevices { + localDevices { + create("pixel6Api34Atd") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + groups { + create("smoke") { + targetDevices.add(devices["pixel6Api34Atd"]) + } + } + } + execution = "ANDROIDX_TEST_ORCHESTRATOR" + animationsDisabled = true + } + buildFeatures { compose = true } @@ -267,6 +286,12 @@ android { androidTestImplementation(libs.androidx.rules) androidTestImplementation(libs.androidx.core.ktx) androidTestImplementation(libs.hamcrest.library) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.contrib) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.test.manifest) + androidTestUtil("androidx.test:orchestrator:1.5.1") // Remote config implementation(project(":android:remote-config")) diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestAppModuleBinds.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestAppModuleBinds.kt new file mode 100644 index 000000000..5f76ceb51 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestAppModuleBinds.kt @@ -0,0 +1,19 @@ +package com.simplecityapps.shuttle.di + +import com.simplecityapps.shuttle.appinitializers.AppInitializer +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import dagger.multibindings.ElementsIntoSet + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AppModuleBinds::class] +) +class TestAppModuleBinds { + @Provides + @ElementsIntoSet + fun provideEmptyInitializers(): Set = emptySet() +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestCastModule.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestCastModule.kt new file mode 100644 index 000000000..7f8c03089 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestCastModule.kt @@ -0,0 +1,48 @@ +package com.simplecityapps.shuttle.di + +import android.content.Context +import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader +import com.simplecityapps.mediaprovider.AggregateMediaInfoProvider +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.chromecast.CastService +import com.simplecityapps.playback.chromecast.CastSessionManager +import com.simplecityapps.playback.chromecast.HttpServer +import com.simplecityapps.playback.di.CastModule +import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [CastModule::class] +) +class TestCastModule { + + @Singleton + @Provides + fun provideCastService( + @ApplicationContext context: Context, + songRepository: SongRepository, + artworkImageLoader: ArtworkImageLoader + ): CastService = CastService(context, songRepository, artworkImageLoader) + + @Singleton + @Provides + fun provideHttpServer(castService: CastService): HttpServer = HttpServer(castService) + + @Singleton + @Provides + fun provideCastSessionManager( + @ApplicationContext context: Context, + playbackManager: PlaybackManager, + httpServer: HttpServer, + exoPlayerPlayback: ExoPlayerPlayback, + mediaInfoProvider: AggregateMediaInfoProvider + ): CastSessionManager = CastSessionManager(playbackManager, context, httpServer, exoPlayerPlayback, mediaInfoProvider) +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestDatabaseModule.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestDatabaseModule.kt new file mode 100644 index 000000000..e5ec1ba98 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestDatabaseModule.kt @@ -0,0 +1,26 @@ +package com.simplecityapps.shuttle.di + +import android.content.Context +import androidx.room.Room +import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class] +) +class TestDatabaseModule { + @Provides + @Singleton + fun provideMediaDatabase( + @ApplicationContext context: Context + ): MediaDatabase = Room.inMemoryDatabaseBuilder(context, MediaDatabase::class.java) + .allowMainThreadQueries() + .build() +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestPlaybackEngineModule.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestPlaybackEngineModule.kt new file mode 100644 index 000000000..05d57392a --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/di/TestPlaybackEngineModule.kt @@ -0,0 +1,90 @@ +package com.simplecityapps.shuttle.di + +import android.content.Context +import android.media.AudioManager +import com.simplecityapps.mediaprovider.AggregateMediaInfoProvider +import com.simplecityapps.playback.AudioEffectSessionManager +import com.simplecityapps.playback.Playback +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.PlaybackWatcher +import com.simplecityapps.playback.audiofocus.AudioFocusHelper +import com.simplecityapps.playback.di.PlaybackEngineModule +import com.simplecityapps.playback.dsp.replaygain.ReplayGainAudioProcessor +import com.simplecityapps.playback.dsp.replaygain.ReplayGainMode +import com.simplecityapps.playback.exoplayer.EqualizerAudioProcessor +import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback +import com.simplecityapps.playback.persistence.PlaybackPreferenceManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.playback.queue.QueueWatcher +import com.simplecityapps.shuttle.fake.FakeAudioFocusHelper +import com.simplecityapps.shuttle.fake.FakePlayback +import dagger.Module +import dagger.Provides +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [PlaybackEngineModule::class] +) +class TestPlaybackEngineModule { + + @Singleton + @Provides + fun providePlayback(): Playback = FakePlayback() + + @Singleton + @Provides + fun provideAudioFocusHelper(): AudioFocusHelper = FakeAudioFocusHelper() + + @Singleton + @Provides + fun provideEqualizerAudioProcessor(): EqualizerAudioProcessor = + EqualizerAudioProcessor(false) + + @Singleton + @Provides + fun provideReplayGainAudioProcessor(): ReplayGainAudioProcessor = + ReplayGainAudioProcessor(ReplayGainMode.Off, 0.0) + + @Singleton + @Provides + fun provideAggregateMediaInfoProvider(): AggregateMediaInfoProvider = + AggregateMediaInfoProvider(mutableSetOf()) + + @Provides + fun provideExoPlayerPlayback( + @ApplicationContext context: Context, + equalizerAudioProcessor: EqualizerAudioProcessor, + replayGainAudioProcessor: ReplayGainAudioProcessor, + mediaInfoProvider: AggregateMediaInfoProvider + ): ExoPlayerPlayback = ExoPlayerPlayback(context, equalizerAudioProcessor, replayGainAudioProcessor, mediaInfoProvider) + + @Singleton + @Provides + fun providePlaybackManager( + queueManager: QueueManager, + playback: Playback, + playbackWatcher: PlaybackWatcher, + audioFocusHelper: AudioFocusHelper, + playbackPreferenceManager: PlaybackPreferenceManager, + audioEffectSessionManager: AudioEffectSessionManager, + @AppCoroutineScope coroutineScope: CoroutineScope, + queueWatcher: QueueWatcher, + audioManager: AudioManager? + ): PlaybackManager = PlaybackManager( + queueManager, + playbackWatcher, + audioFocusHelper, + playbackPreferenceManager, + audioEffectSessionManager, + coroutineScope, + playback, + queueWatcher, + audioManager + ) +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakeAudioFocusHelper.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakeAudioFocusHelper.kt new file mode 100644 index 000000000..12bdde146 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakeAudioFocusHelper.kt @@ -0,0 +1,12 @@ +package com.simplecityapps.shuttle.fake + +import com.simplecityapps.playback.audiofocus.AudioFocusHelper + +class FakeAudioFocusHelper : AudioFocusHelper { + override var listener: AudioFocusHelper.Listener? = null + override var enabled: Boolean = true + override var resumeOnFocusGain: Boolean = true + + override fun requestAudioFocus(): Boolean = true + override fun abandonAudioFocus() {} +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakePlayback.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakePlayback.kt new file mode 100644 index 000000000..610b5821d --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/fake/FakePlayback.kt @@ -0,0 +1,58 @@ +package com.simplecityapps.shuttle.fake + +import com.simplecityapps.playback.Playback +import com.simplecityapps.playback.PlaybackState +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.shuttle.model.Song + +class FakePlayback : Playback { + override var callback: Playback.Callback? = null + override var isReleased: Boolean = false + + private var state: PlaybackState = PlaybackState.Paused + private var progress: Int = 0 + private var duration: Int = 0 + private var volume: Float = 1.0f + private var speed: Float = 1.0f + private var repeatMode: QueueManager.RepeatMode = QueueManager.RepeatMode.Off + + override suspend fun load( + current: Song, + next: Song?, + seekPosition: Int, + completion: (Result) -> Unit + ) { + isReleased = false + progress = seekPosition + duration = current.duration + state = PlaybackState.Loading + callback?.onPlaybackStateChanged(state) + completion(Result.success(null)) + } + + override suspend fun loadNext(song: Song?) {} + + override fun play() { + state = PlaybackState.Playing + callback?.onPlaybackStateChanged(state) + } + + override fun pause() { + state = PlaybackState.Paused + callback?.onPlaybackStateChanged(state) + } + + override fun release() { + isReleased = true + state = PlaybackState.Paused + } + + override fun playBackState(): PlaybackState = state + override fun seek(position: Int) { progress = position } + override fun getProgress(): Int = progress + override fun getDuration(): Int = duration + override fun setVolume(volume: Float) { this.volume = volume } + override fun setRepeatMode(repeatMode: QueueManager.RepeatMode) { this.repeatMode = repeatMode } + override fun setPlaybackSpeed(multiplier: Float) { speed = multiplier } + override fun getPlaybackSpeed(): Float = speed +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/EspressoUtils.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/EspressoUtils.kt new file mode 100644 index 000000000..dfd62fdc3 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/EspressoUtils.kt @@ -0,0 +1,26 @@ +package com.simplecityapps.shuttle.smoke + +import android.view.View +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import org.hamcrest.Matcher + +/** + * Waits for a view matching [viewMatcher] to become displayed, polling every 100ms. + * Returns as soon as the view is found. Throws after [timeoutMs] if not found. + */ +fun waitForView(viewMatcher: Matcher, timeoutMs: Long = 3000) { + val end = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + while (System.currentTimeMillis() < end) { + try { + onView(viewMatcher).check(matches(isDisplayed())) + return + } catch (e: Throwable) { + lastError = e + Thread.sleep(100) + } + } + throw lastError ?: AssertionError("Timed out waiting for view: $viewMatcher") +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt new file mode 100644 index 000000000..7c098fb1f --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/NavigationSmokeTest.kt @@ -0,0 +1,165 @@ +package com.simplecityapps.shuttle.smoke + +import android.Manifest +import android.content.SharedPreferences +import android.os.Build +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.GrantPermissionRule +import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.ui.MainActivity +import com.simplecityapps.shuttle.ui.common.view.multisheet.MultiSheetView +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import org.hamcrest.CoreMatchers.allOf +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class NavigationSmokeTest { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.READ_MEDIA_AUDIO) + } else { + GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + @Inject + lateinit var database: MediaDatabase + + @Inject + lateinit var sharedPreferences: SharedPreferences + + lateinit var scenario: ActivityScenario + + @Before + fun setup() { + hiltRule.inject() + SmokeTestData.seedDatabase(database) + SmokeTestData.setOnboarded(sharedPreferences) + } + + @After + fun cleanup() { + if (::scenario.isInitialized) { + scenario.close() + } + } + + /** + * Navigates to all bottom nav destinations and all library tabs. + */ + @Test + fun bottomNavAndTabs() { + scenario = launchActivity() + + // Home + onView(withId(R.id.homeFragment)).perform(click()) + waitForView(allOf(withId(R.id.shuffleButton), isDescendantOfA(withId(R.id.appBarLayout)))) + + // Library + onView(withId(R.id.libraryFragment)).perform(click()) + waitForView(allOf(withId(R.id.tabLayout), isDescendantOfA(withId(R.id.constraintLayout)))) + + // Search + onView(withId(R.id.searchFragment)).perform(click()) + waitForView(withId(R.id.searchView)) + + // Settings dialog + onView(withId(R.id.bottomSheetFragment)).perform(click()) + waitForView(withText("Settings")) + Espresso.pressBack() + + // Library tabs + onView(withId(R.id.libraryFragment)).perform(click()) + + onView(withText("Songs")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(withText("Highway to Hell")))) + + onView(withText("Albums")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + + onView(withText("Artists")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + + onView(withText("Genres")).perform(click()) + // Genres uses Compose — just verify the tab didn't crash + waitForView(allOf(withId(R.id.tabLayout), isDescendantOfA(withId(R.id.constraintLayout)))) + + onView(withText("Playlists")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), isDisplayed())) + } + + /** + * Navigates to artist detail and album detail screens. + */ + @Test + fun detailScreens() { + scenario = launchActivity() + + // Artist detail + onView(withId(R.id.libraryFragment)).perform(click()) + onView(withText("Artists")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + waitForView(allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.collapsingToolbarLayout)))) + Espresso.pressBack() + + // Album detail — position 1 because position 0 is the shuffle header + waitForView(withText("Albums")) + onView(withText("Albums")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(1, click())) + waitForView(allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.collapsingToolbarLayout)))) + } + + /** + * Navigates through playback screens: mini player, now playing, queue. + */ + @Test + fun playbackScreens() { + scenario = launchActivity() + + // Play a song + onView(withId(R.id.libraryFragment)).perform(click()) + onView(withText("Songs")).perform(click()) + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + // Mini player + waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) + + // Now Playing + onView(withId(R.id.sheet1PeekView)).perform(click()) + waitForView(allOf(withId(R.id.playPauseButton), isDescendantOfA(withId(R.id.sheet1Container)), isClickable())) + + // Queue + scenario.onActivity { activity -> + val multiSheetView = activity.findViewById(R.id.multiSheetView) + multiSheetView.goToSheet(MultiSheetView.Sheet.SECOND) + } + waitForView(allOf(withId(R.id.toolbarTitleTextView), withText("Up Next"))) + } +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestData.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestData.kt new file mode 100644 index 000000000..d43b5963e --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestData.kt @@ -0,0 +1,75 @@ +package com.simplecityapps.shuttle.smoke + +import android.content.SharedPreferences +import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase +import com.simplecityapps.localmediaprovider.local.data.room.entity.SongData +import com.simplecityapps.shuttle.model.MediaProviderType +import java.util.Date +import kotlinx.coroutines.runBlocking + +object SmokeTestData { + + fun seedDatabase(database: MediaDatabase) { + val dao = database.songDataDao() + val songs = buildSongList() + runBlocking { + dao.insert(songs) + } + } + + fun setOnboarded(sharedPreferences: SharedPreferences) { + sharedPreferences.edit() + .putBoolean("has_onboarded", true) + .putBoolean("thank_you_dialog_viewed", true) + .putBoolean("crash_reporting_dialog_viewed", true) + .putBoolean("changelog_show_on_launch", false) + .commit() + } + + private fun buildSongList(): List { + val now = Date() + return listOf( + songData("Highway to Hell", "AC/DC", "Back in Black", 1, 210000, "/music/01.mp3", now), + songData("Thunderstruck", "AC/DC", "The Razors Edge", 1, 292000, "/music/02.mp3", now), + songData("Back in Black", "AC/DC", "Back in Black", 2, 255000, "/music/03.mp3", now), + songData("Bohemian Rhapsody", "Queen", "A Night at the Opera", 1, 354000, "/music/04.mp3", now), + songData("Don't Stop Me Now", "Queen", "Jazz", 1, 209000, "/music/05.mp3", now), + songData("Somebody to Love", "Queen", "A Day at the Races", 1, 297000, "/music/06.mp3", now), + songData("Stairway to Heaven", "Led Zeppelin", "Led Zeppelin IV", 4, 482000, "/music/07.mp3", now), + songData("Whole Lotta Love", "Led Zeppelin", "Led Zeppelin II", 1, 333000, "/music/08.mp3", now), + songData("Black Dog", "Led Zeppelin", "Led Zeppelin IV", 1, 296000, "/music/09.mp3", now), + songData("Immigrant Song", "Led Zeppelin", "Led Zeppelin III", 1, 146000, "/music/10.mp3", now), + ) + } + + private fun songData( + name: String, + albumArtist: String, + album: String, + track: Int, + duration: Int, + path: String, + lastModified: Date + ): SongData = SongData( + name = name, + track = track, + disc = 1, + duration = duration, + year = 1975, + genres = listOf("Rock"), + path = path, + albumArtist = albumArtist, + artists = listOf(albumArtist), + album = album, + size = 5_000_000, + mimeType = "audio/mpeg", + lastModified = lastModified, + mediaProvider = MediaProviderType.Shuttle, + lyrics = null, + grouping = null, + bitRate = 320, + bitDepth = 16, + sampleRate = 44100, + channelCount = 2 + ) +} diff --git a/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt new file mode 100644 index 000000000..44651ff68 --- /dev/null +++ b/android/app/src/androidTest/java/com/simplecityapps/shuttle/smoke/SmokeTestSuite.kt @@ -0,0 +1,291 @@ +package com.simplecityapps.shuttle.smoke + +import android.Manifest +import android.content.SharedPreferences +import android.os.Build +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.rule.GrantPermissionRule +import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase +import com.simplecityapps.shuttle.R +import com.simplecityapps.shuttle.ui.MainActivity +import com.simplecityapps.shuttle.ui.common.view.multisheet.MultiSheetView +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import org.hamcrest.CoreMatchers.allOf +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class SmokeTestSuite { + + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + var permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.READ_MEDIA_AUDIO) + } else { + GrantPermissionRule.grant(Manifest.permission.READ_EXTERNAL_STORAGE) + } + + @Inject + lateinit var database: MediaDatabase + + @Inject + lateinit var sharedPreferences: SharedPreferences + + lateinit var scenario: ActivityScenario + + @Before + fun setup() { + hiltRule.inject() + SmokeTestData.seedDatabase(database) + SmokeTestData.setOnboarded(sharedPreferences) + } + + @After + fun cleanup() { + if (::scenario.isInitialized) { + scenario.close() + } + } + + @Test + fun appLaunches_and_libraryShowsSongs() { + scenario = launchActivity() + + // Bottom nav should be visible + onView(withId(R.id.bottomNavigationView)) + .check(matches(isDisplayed())) + + // Navigate to Library tab + onView(withId(R.id.libraryFragment)) + .perform(click()) + + // Tab layout should be visible (use isDescendantOfA to avoid matching the debug drawer's tabLayout) + onView(allOf(withId(R.id.tabLayout), isDescendantOfA(withId(R.id.constraintLayout)))) + .check(matches(isDisplayed())) + } + + @Test + fun libraryTabs_showContent() { + scenario = launchActivity() + + // Navigate to Library + onView(withId(R.id.libraryFragment)) + .perform(click()) + + // Click the Songs tab + onView(withText("Songs")) + .perform(click()) + + // Wait for Flow to emit data, then verify a song from our test data is visible + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(withText("Highway to Hell")))) + } + + @Test + fun tapSong_miniPlayerShowsSongTitle() { + scenario = launchActivity() + + // Navigate to Library > Songs + onView(withId(R.id.libraryFragment)) + .perform(click()) + onView(withText("Songs")) + .perform(click()) + + // Wait for Flow to emit data, then tap the first song in the list + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + // Mini player should show a song title + waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) + } + + @Test + fun search_isAccessible() { + scenario = launchActivity() + + // Navigate to Search + onView(withId(R.id.searchFragment)) + .perform(click()) + + // The search view should be displayed + onView(withId(R.id.searchView)) + .check(matches(isDisplayed())) + } + + @Test + fun artistDrillDown_showsArtistDetail() { + scenario = launchActivity() + + // Navigate to Library > Artists tab + onView(withId(R.id.libraryFragment)) + .perform(click()) + onView(withText("Artists")) + .perform(click()) + + // Wait for Flow to emit data, then tap the first artist + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + // Verify artist detail screen shows toolbar (scoped to CollapsingToolbarLayout to avoid ambiguity) + waitForView(allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.collapsingToolbarLayout)))) + } + + @Test + fun tapSong_expandNowPlaying_showsControls() { + scenario = launchActivity() + + // Navigate to Library > Songs tab and play a song + onView(withId(R.id.libraryFragment)) + .perform(click()) + onView(withText("Songs")) + .perform(click()) + + // Wait for Flow to emit data, then tap the first song + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + // Wait for mini player, then expand to full playback + waitForView(allOf(withId(R.id.sheet1PeekView), isDisplayed())) + onView(withId(R.id.sheet1PeekView)) + .perform(click()) + + // Verify full playback controls are visible (scoped to sheet1Container, isClickable to avoid child view ambiguity) + waitForView(allOf(withId(R.id.playPauseButton), isDescendantOfA(withId(R.id.sheet1Container)), isClickable())) + onView(allOf(withId(R.id.seekBar), isDescendantOfA(withId(R.id.sheet1Container)))) + .check(matches(isDisplayed())) + onView(allOf(withId(R.id.shuffleButton), isDescendantOfA(withId(R.id.sheet1Container)))) + .check(matches(isDisplayed())) + } + + @Test + fun playingSong_queueShowsItems() { + scenario = launchActivity() + + // Navigate to Library > Songs tab and play a song + onView(withId(R.id.libraryFragment)) + .perform(click()) + onView(withText("Songs")) + .perform(click()) + + // Wait for Flow to emit data, then tap the first song + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(isDisplayed()))) + onView(allOf(withId(R.id.recyclerView), isDisplayed())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, click())) + + // Wait for mini player to appear before expanding to queue + waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) + + // Programmatically expand to sheet 2 (queue) via MultiSheetView + scenario.onActivity { activity -> + val multiSheetView = activity.findViewById(R.id.multiSheetView) + multiSheetView.goToSheet(MultiSheetView.Sheet.SECOND) + } + + // Verify queue toolbar shows "Up Next" + waitForView(allOf(withId(R.id.toolbarTitleTextView), withText("Up Next"))) + + // Verify queue recyclerView is displayed (has items) + onView(allOf(withId(R.id.recyclerView), isDescendantOfA(withId(R.id.sheet2Container)))) + .check(matches(isDisplayed())) + } + + @Test + fun homeScreen_showsShuffleButton() { + scenario = launchActivity() + + // Navigate to Home tab + onView(withId(R.id.homeFragment)) + .perform(click()) + + // Verify key home buttons are visible (scoped to appBarLayout to avoid playback ambiguity) + onView(allOf(withId(R.id.shuffleButton), isDescendantOfA(withId(R.id.appBarLayout)))) + .check(matches(isDisplayed())) + onView(allOf(withId(R.id.historyButton), isDescendantOfA(withId(R.id.appBarLayout)))) + .check(matches(isDisplayed())) + } + + @Test + fun shuffleAll_startsPlayback() { + scenario = launchActivity() + + // Navigate to Home tab + onView(withId(R.id.homeFragment)) + .perform(click()) + + // Tap shuffle button (scoped to appBarLayout to avoid playback ambiguity) + onView(allOf(withId(R.id.shuffleButton), isDescendantOfA(withId(R.id.appBarLayout)))) + .perform(click()) + + // Verify mini player shows a song title + waitForView(allOf(withId(R.id.titleTextView), isDescendantOfA(withId(R.id.sheet1PeekView)))) + } + + @Test + fun search_typingQuery_showsResults() { + scenario = launchActivity() + + // Navigate to Search + onView(withId(R.id.searchFragment)) + .perform(click()) + + // Click the SearchView to activate it, then type into the inner EditText + onView(withId(R.id.searchView)) + .perform(click()) + onView(isAssignableFrom(android.widget.EditText::class.java)) + .perform(typeText("Queen"), closeSoftKeyboard()) + + // Wait for debounce + query results + waitForView(allOf(withId(R.id.recyclerView), hasDescendant(withText("Queen")))) + } + + @Test + fun playlistsTab_isAccessible() { + scenario = launchActivity() + + // Navigate to Library + onView(withId(R.id.libraryFragment)) + .perform(click()) + + // Click Playlists tab + onView(withText("Playlists")) + .perform(click()) + + // Verify the fragment loaded — the recyclerView should exist even if empty + waitForView(allOf(withId(R.id.recyclerView), isDisplayed())) + } + + @Test + fun settings_isAccessible() { + scenario = launchActivity() + + // Tap the settings/menu bottom nav item + onView(withId(R.id.bottomSheetFragment)) + .perform(click()) + + // Verify the bottom drawer is showing by checking for a known menu item + waitForView(withText("Settings")) + } +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/di/DatabaseModule.kt b/android/app/src/main/java/com/simplecityapps/shuttle/di/DatabaseModule.kt new file mode 100644 index 000000000..9d2a0647b --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/di/DatabaseModule.kt @@ -0,0 +1,21 @@ +package com.simplecityapps.shuttle.di + +import android.content.Context +import com.simplecityapps.localmediaprovider.local.data.room.DatabaseProvider +import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class DatabaseModule { + @Provides + @Singleton + fun provideMediaDatabase( + @ApplicationContext context: Context + ): MediaDatabase = DatabaseProvider(context).database +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt index 3ad54333e..17fe6c4c9 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/di/RepositoryModule.kt @@ -1,7 +1,6 @@ package com.simplecityapps.shuttle.di import android.content.Context -import com.simplecityapps.localmediaprovider.local.data.room.DatabaseProvider import com.simplecityapps.localmediaprovider.local.data.room.database.MediaDatabase import com.simplecityapps.localmediaprovider.local.repository.LocalAlbumArtistRepository import com.simplecityapps.localmediaprovider.local.repository.LocalAlbumRepository @@ -26,12 +25,6 @@ import kotlinx.coroutines.CoroutineScope @InstallIn(SingletonComponent::class) @Module class RepositoryModule { - @Provides - @Singleton - fun provideMediaDatabase( - @ApplicationContext context: Context - ): MediaDatabase = DatabaseProvider(context).database - @Provides @Singleton fun provideSongRepository( diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/queue/QueueFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/queue/QueueFragment.kt index 2f5f1f328..de1b9a332 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/queue/QueueFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/queue/QueueFragment.kt @@ -66,10 +66,10 @@ class QueueFragment : private var recyclerView: FastScrollRecyclerView? = null private var toolbar: Toolbar? by autoClearedNullable() - private var toolbarTitleTextView: TextView by autoCleared() - private var toolbarSubtitleTextView: TextView by autoCleared() - private var progressBar: ProgressBar by autoCleared() - private var emptyLabel: TextView by autoCleared() + private var toolbarTitleTextView: TextView? by autoClearedNullable() + private var toolbarSubtitleTextView: TextView? by autoClearedNullable() + private var progressBar: ProgressBar? by autoClearedNullable() + private var emptyLabel: TextView? by autoClearedNullable() @Inject lateinit var presenter: QueuePresenter @@ -269,11 +269,11 @@ class QueueFragment : } override fun toggleEmptyView(empty: Boolean) { - emptyLabel.isVisible = empty + emptyLabel?.isVisible = empty } override fun toggleLoadingView(loading: Boolean) { - progressBar.isVisible = loading + progressBar?.isVisible = loading } override fun setQueuePosition( @@ -281,7 +281,7 @@ class QueueFragment : total: Int ) { position?.let { - toolbarSubtitleTextView.text = + toolbarSubtitleTextView?.text = Phrase.from(requireContext(), R.string.queue_position) .put("position", (position + 1).toString()) .put("total", total.toString()) @@ -402,7 +402,7 @@ class QueueFragment : slideOffset: Float ) { if (sheet == MultiSheetView.Sheet.SECOND) { - toolbarTitleTextView.textSize = 15 + (5 * slideOffset) + toolbarTitleTextView?.textSize = 15 + (5 * slideOffset) } } } diff --git a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt index b03ccfc31..1a9f555b9 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/PlaybackManager.kt @@ -4,7 +4,6 @@ import android.media.AudioManager import android.os.Handler import android.os.Looper import com.simplecityapps.playback.audiofocus.AudioFocusHelper -import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback import com.simplecityapps.playback.persistence.PlaybackPreferenceManager import com.simplecityapps.playback.queue.QueueChangeCallback import com.simplecityapps.playback.queue.QueueItem @@ -24,7 +23,7 @@ class PlaybackManager( private val playbackPreferenceManager: PlaybackPreferenceManager, private val audioEffectSessionManager: AudioEffectSessionManager, private val appCoroutineScope: CoroutineScope, - exoplayerPlayback: ExoPlayerPlayback, + exoplayerPlayback: Playback, queueWatcher: QueueWatcher, audioManager: AudioManager? ) : Playback.Callback, diff --git a/android/playback/src/main/java/com/simplecityapps/playback/di/CastModule.kt b/android/playback/src/main/java/com/simplecityapps/playback/di/CastModule.kt new file mode 100644 index 000000000..71f0c1db6 --- /dev/null +++ b/android/playback/src/main/java/com/simplecityapps/playback/di/CastModule.kt @@ -0,0 +1,43 @@ +package com.simplecityapps.playback.di + +import android.content.Context +import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader +import com.simplecityapps.mediaprovider.AggregateMediaInfoProvider +import com.simplecityapps.mediaprovider.repository.songs.SongRepository +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.chromecast.CastService +import com.simplecityapps.playback.chromecast.CastSessionManager +import com.simplecityapps.playback.chromecast.HttpServer +import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class CastModule { + @Singleton + @Provides + fun provideCastService( + @ApplicationContext context: Context, + songRepository: SongRepository, + artworkImageLoader: ArtworkImageLoader + ): CastService = CastService(context, songRepository, artworkImageLoader) + + @Singleton + @Provides + fun provideHttpServer(castService: CastService): HttpServer = HttpServer(castService) + + @Singleton + @Provides + fun provideCastSessionManager( + @ApplicationContext context: Context, + playbackManager: PlaybackManager, + httpServer: HttpServer, + exoPlayerPlayback: ExoPlayerPlayback, + mediaPathProvider: AggregateMediaInfoProvider + ): CastSessionManager = CastSessionManager(playbackManager, context, httpServer, exoPlayerPlayback, mediaPathProvider) +} diff --git a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackEngineModule.kt b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackEngineModule.kt new file mode 100644 index 000000000..7841e35de --- /dev/null +++ b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackEngineModule.kt @@ -0,0 +1,107 @@ +package com.simplecityapps.playback.di + +import android.content.Context +import android.os.Build +import com.simplecityapps.mediaprovider.AggregateMediaInfoProvider +import com.simplecityapps.playback.Playback +import com.simplecityapps.playback.PlaybackManager +import com.simplecityapps.playback.PlaybackWatcher +import com.simplecityapps.playback.AudioEffectSessionManager +import com.simplecityapps.playback.audiofocus.AudioFocusHelper +import com.simplecityapps.playback.audiofocus.AudioFocusHelperApi21 +import com.simplecityapps.playback.audiofocus.AudioFocusHelperApi26 +import com.simplecityapps.playback.dsp.equalizer.Equalizer +import com.simplecityapps.playback.dsp.replaygain.ReplayGainAudioProcessor +import com.simplecityapps.playback.exoplayer.EqualizerAudioProcessor +import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback +import com.simplecityapps.playback.persistence.PlaybackPreferenceManager +import com.simplecityapps.playback.queue.QueueManager +import com.simplecityapps.playback.queue.QueueWatcher +import com.simplecityapps.provider.emby.EmbyMediaInfoProvider +import com.simplecityapps.provider.jellyfin.JellyfinMediaInfoProvider +import com.simplecityapps.provider.plex.PlexMediaInfoProvider +import com.simplecityapps.shuttle.di.AppCoroutineScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import android.media.AudioManager + +@InstallIn(SingletonComponent::class) +@Module +class PlaybackEngineModule { + @Singleton + @Provides + fun provideEqualizer(playbackPreferenceManager: PlaybackPreferenceManager): EqualizerAudioProcessor = EqualizerAudioProcessor(playbackPreferenceManager.equalizerEnabled).apply { + // Restore current eq + preset = playbackPreferenceManager.preset + + // Restore custom eq bands + playbackPreferenceManager.customPresetBands?.forEach { restoredBand -> + Equalizer.Presets.custom.bands.forEach { customBand -> + if (customBand.centerFrequency == restoredBand.centerFrequency) { + customBand.gain = restoredBand.gain + } + } + } + } + + @Singleton + @Provides + fun provideReplayGainAudioProcessor(playbackPreferenceManager: PlaybackPreferenceManager): ReplayGainAudioProcessor = ReplayGainAudioProcessor(playbackPreferenceManager.replayGainMode, playbackPreferenceManager.preAmpGain) + + @Singleton + @Provides + fun provideAggregateMediaPathProvider( + embyMediaPathProvider: EmbyMediaInfoProvider, + jellyfinMediaPathProvider: JellyfinMediaInfoProvider, + plexMediaPathProvider: PlexMediaInfoProvider + ): AggregateMediaInfoProvider = AggregateMediaInfoProvider( + mutableSetOf( + embyMediaPathProvider, + jellyfinMediaPathProvider, + plexMediaPathProvider + ) + ) + + @Provides + fun provideExoPlayerPlayback( + @ApplicationContext context: Context, + equalizerAudioProcessor: EqualizerAudioProcessor, + replayGainAudioProcessor: ReplayGainAudioProcessor, + mediaPathProvider: AggregateMediaInfoProvider + ): ExoPlayerPlayback = ExoPlayerPlayback(context, equalizerAudioProcessor, replayGainAudioProcessor, mediaPathProvider) + + @Provides + fun providePlayback(exoPlayerPlayback: ExoPlayerPlayback): Playback = exoPlayerPlayback + + @Singleton + @Provides + fun provideAudioFocusHelper( + @ApplicationContext context: Context, + playbackWatcher: PlaybackWatcher + ): AudioFocusHelper { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return AudioFocusHelperApi26(context, playbackWatcher) + } else { + return AudioFocusHelperApi21(context, playbackWatcher) + } + } + + @Singleton + @Provides + fun providePlaybackManager( + queueManager: QueueManager, + playback: Playback, + playbackWatcher: PlaybackWatcher, + audioFocusHelper: AudioFocusHelper, + playbackPreferenceManager: PlaybackPreferenceManager, + audioEffectSessionManager: AudioEffectSessionManager, + @AppCoroutineScope coroutineScope: CoroutineScope, + queueWatcher: QueueWatcher, + audioManager: AudioManager? + ): PlaybackManager = PlaybackManager(queueManager, playbackWatcher, audioFocusHelper, playbackPreferenceManager, audioEffectSessionManager, coroutineScope, playback, queueWatcher, audioManager) +} diff --git a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt index 20d241eef..da8433afd 100644 --- a/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt +++ b/android/playback/src/main/java/com/simplecityapps/playback/di/PlaybackModule.kt @@ -4,11 +4,9 @@ import android.content.Context import android.content.SharedPreferences import android.graphics.Bitmap import android.media.AudioManager -import android.os.Build import android.util.LruCache import androidx.core.content.getSystemService import au.com.simplecityapps.shuttle.imageloading.ArtworkImageLoader -import com.simplecityapps.mediaprovider.AggregateMediaInfoProvider import com.simplecityapps.mediaprovider.repository.albums.AlbumRepository import com.simplecityapps.mediaprovider.repository.artists.AlbumArtistRepository import com.simplecityapps.mediaprovider.repository.genres.GenreRepository @@ -20,24 +18,11 @@ import com.simplecityapps.playback.PlaybackManager import com.simplecityapps.playback.PlaybackNotificationManager import com.simplecityapps.playback.PlaybackWatcher import com.simplecityapps.playback.androidauto.MediaIdHelper -import com.simplecityapps.playback.audiofocus.AudioFocusHelper -import com.simplecityapps.playback.audiofocus.AudioFocusHelperApi21 -import com.simplecityapps.playback.audiofocus.AudioFocusHelperApi26 -import com.simplecityapps.playback.chromecast.CastService -import com.simplecityapps.playback.chromecast.CastSessionManager -import com.simplecityapps.playback.chromecast.HttpServer -import com.simplecityapps.playback.dsp.equalizer.Equalizer -import com.simplecityapps.playback.dsp.replaygain.ReplayGainAudioProcessor -import com.simplecityapps.playback.exoplayer.EqualizerAudioProcessor -import com.simplecityapps.playback.exoplayer.ExoPlayerPlayback import com.simplecityapps.playback.mediasession.MediaSessionManager import com.simplecityapps.playback.persistence.PlaybackPreferenceManager import com.simplecityapps.playback.queue.QueueManager import com.simplecityapps.playback.queue.QueueWatcher import com.simplecityapps.playback.sleeptimer.SleepTimer -import com.simplecityapps.provider.emby.EmbyMediaInfoProvider -import com.simplecityapps.provider.jellyfin.JellyfinMediaInfoProvider -import com.simplecityapps.provider.plex.PlexMediaInfoProvider import com.simplecityapps.shuttle.di.AppCoroutineScope import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager import com.squareup.moshi.Moshi @@ -63,26 +48,6 @@ class PlaybackModule { preferenceManager: GeneralPreferenceManager ): QueueManager = QueueManager(queueWatcher, preferenceManager) - @Singleton - @Provides - fun provideEqualizer(playbackPreferenceManager: PlaybackPreferenceManager): EqualizerAudioProcessor = EqualizerAudioProcessor(playbackPreferenceManager.equalizerEnabled).apply { - // Restore current eq - preset = playbackPreferenceManager.preset - - // Restore custom eq bands - playbackPreferenceManager.customPresetBands?.forEach { restoredBand -> - Equalizer.Presets.custom.bands.forEach { customBand -> - if (customBand.centerFrequency == restoredBand.centerFrequency) { - customBand.gain = restoredBand.gain - } - } - } - } - - @Singleton - @Provides - fun provideReplayGainAudioProcessor(playbackPreferenceManager: PlaybackPreferenceManager): ReplayGainAudioProcessor = ReplayGainAudioProcessor(playbackPreferenceManager.replayGainMode, playbackPreferenceManager.preAmpGain) - @Singleton @Provides fun providePlaybackPreferenceManager( @@ -90,45 +55,10 @@ class PlaybackModule { moshi: Moshi ): PlaybackPreferenceManager = PlaybackPreferenceManager(sharedPreferences, moshi) - @Singleton - @Provides - fun provideAggregateMediaPathProvider( - embyMediaPathProvider: EmbyMediaInfoProvider, - jellyfinMediaPathProvider: JellyfinMediaInfoProvider, - plexMediaPathProvider: PlexMediaInfoProvider - ): AggregateMediaInfoProvider = AggregateMediaInfoProvider( - mutableSetOf( - embyMediaPathProvider, - jellyfinMediaPathProvider, - plexMediaPathProvider - ) - ) - - @Provides - fun provideExoPlayerPlayback( - @ApplicationContext context: Context, - equalizerAudioProcessor: EqualizerAudioProcessor, - replayGainAudioProcessor: ReplayGainAudioProcessor, - mediaPathProvider: AggregateMediaInfoProvider - ): ExoPlayerPlayback = ExoPlayerPlayback(context, equalizerAudioProcessor, replayGainAudioProcessor, mediaPathProvider) - @Singleton @Provides fun providePlaybackWatcher(): PlaybackWatcher = PlaybackWatcher() - @Singleton - @Provides - fun provideAudioFocusHelper( - @ApplicationContext context: Context, - playbackWatcher: PlaybackWatcher - ): AudioFocusHelper { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return AudioFocusHelperApi26(context, playbackWatcher) - } else { - return AudioFocusHelperApi21(context, playbackWatcher) - } - } - @Provides fun provideMediaIdHelper( playlistRepository: PlaylistRepository, @@ -147,42 +77,6 @@ class PlaybackModule { @ApplicationContext context: Context ): AudioEffectSessionManager = AudioEffectSessionManager(context) - @Singleton - @Provides - fun providePlaybackManager( - queueManager: QueueManager, - playback: ExoPlayerPlayback, - playbackWatcher: PlaybackWatcher, - audioFocusHelper: AudioFocusHelper, - playbackPreferenceManager: PlaybackPreferenceManager, - audioEffectSessionManager: AudioEffectSessionManager, - @AppCoroutineScope coroutineScope: CoroutineScope, - queueWatcher: QueueWatcher, - audioManager: AudioManager? - ): PlaybackManager = PlaybackManager(queueManager, playbackWatcher, audioFocusHelper, playbackPreferenceManager, audioEffectSessionManager, coroutineScope, playback, queueWatcher, audioManager) - - @Singleton - @Provides - fun provideCastService( - @ApplicationContext context: Context, - songRepository: SongRepository, - artworkImageLoader: ArtworkImageLoader - ): CastService = CastService(context, songRepository, artworkImageLoader) - - @Singleton - @Provides - fun provideHttpServer(castService: CastService): HttpServer = HttpServer(castService) - - @Singleton - @Provides - fun provideCastSessionManager( - @ApplicationContext context: Context, - playbackManager: PlaybackManager, - httpServer: HttpServer, - exoPlayerPlayback: ExoPlayerPlayback, - mediaPathProvider: AggregateMediaInfoProvider - ): CastSessionManager = CastSessionManager(playbackManager, context, httpServer, exoPlayerPlayback, mediaPathProvider) - @Singleton @Provides fun provideMediaSessionManager( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 977ef8d08..5df8ce0bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -85,6 +85,7 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo androidx-core-ktx = { module = "androidx.test:core-ktx", version.ref = "core-ktx-version" } androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } androidx-drawerlayout = { module = "androidx.drawerlayout:drawerlayout", version.ref = "drawerlayout" } +androidx-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso-core" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso-core" } androidx-foundation = { module = "androidx.compose.foundation:foundation" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment-ktx" } @@ -112,6 +113,8 @@ androidx-rules = { module = "androidx.test:rules", version.ref = "core-ktx-versi androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } androidx-ui = { module = "androidx.compose.ui:ui" } +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }