├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── ic_launcher_plain-playstore.png
│ │ ├── res
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher_plain.webp
│ │ │ │ └── ic_launcher_plain_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher_plain.webp
│ │ │ │ └── ic_launcher_plain_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher_plain.webp
│ │ │ │ └── ic_launcher_plain_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher_plain.webp
│ │ │ │ └── ic_launcher_plain_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher_plain.webp
│ │ │ │ └── ic_launcher_plain_round.webp
│ │ │ ├── values-night
│ │ │ │ └── colors.xml
│ │ │ ├── values
│ │ │ │ ├── styles.xml
│ │ │ │ └── colors.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher_plain.xml
│ │ │ │ └── ic_launcher_plain_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── ic_stop.xml
│ │ │ │ ├── ic_skip_next.xml
│ │ │ │ ├── ic_skip_previous.xml
│ │ │ │ ├── ic_play_pause.xml
│ │ │ │ ├── ic_close.xml
│ │ │ │ ├── ic_volume_down.xml
│ │ │ │ ├── ic_volume_mute.xml
│ │ │ │ ├── ic_volume_up.xml
│ │ │ │ ├── ic_notification.xml
│ │ │ │ └── ic_launcher_plain_foreground.xml
│ │ │ └── values-ru
│ │ │ │ └── keys.xml
│ │ ├── java
│ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── soundremote
│ │ │ │ ├── audio
│ │ │ │ ├── decoder
│ │ │ │ │ ├── DecoderException.kt
│ │ │ │ │ └── OpusAudioDecoder.kt
│ │ │ │ └── AudioPipe.kt
│ │ │ │ ├── util
│ │ │ │ ├── TestTag.kt
│ │ │ │ ├── Settings.kt
│ │ │ │ ├── Audio.kt
│ │ │ │ └── System.kt
│ │ │ │ ├── network
│ │ │ │ ├── DisconnectData.kt
│ │ │ │ ├── KeepAliveData.kt
│ │ │ │ ├── PacketData.kt
│ │ │ │ ├── HotkeyData.kt
│ │ │ │ ├── SetFormatData.kt
│ │ │ │ ├── ConnectData.kt
│ │ │ │ ├── AckConnectData.kt
│ │ │ │ ├── AckData.kt
│ │ │ │ └── PacketHeader.kt
│ │ │ │ ├── SoundRemoteApplication.kt
│ │ │ │ ├── service
│ │ │ │ ├── ServiceModule.kt
│ │ │ │ ├── ServiceManager.kt
│ │ │ │ └── MainServiceManager.kt
│ │ │ │ ├── data
│ │ │ │ ├── EventActionRepository.kt
│ │ │ │ ├── EventAction.kt
│ │ │ │ ├── ActionType.kt
│ │ │ │ ├── HotkeyRepository.kt
│ │ │ │ ├── Action.kt
│ │ │ │ ├── room
│ │ │ │ │ ├── BaseDao.kt
│ │ │ │ │ ├── EventActionDao.kt
│ │ │ │ │ ├── DatabaseMigrations.kt
│ │ │ │ │ ├── AppDatabase.kt
│ │ │ │ │ └── HotkeyDao.kt
│ │ │ │ ├── AppAction.kt
│ │ │ │ ├── RepositoryModule.kt
│ │ │ │ ├── preferences
│ │ │ │ │ ├── PreferencesRepository.kt
│ │ │ │ │ └── DataStoreModule.kt
│ │ │ │ ├── DatabaseModule.kt
│ │ │ │ ├── Event.kt
│ │ │ │ ├── SystemEventActionRepository.kt
│ │ │ │ ├── UserHotkeyRepository.kt
│ │ │ │ └── Hotkey.kt
│ │ │ │ ├── ui
│ │ │ │ ├── about
│ │ │ │ │ └── AboutNavigation.kt
│ │ │ │ ├── components
│ │ │ │ │ ├── ListItemHeadline.kt
│ │ │ │ │ ├── NavigateUpButton.kt
│ │ │ │ │ ├── ListItemSupport.kt
│ │ │ │ │ └── HotkeySelectViewModel.kt
│ │ │ │ ├── settings
│ │ │ │ │ ├── PreferenceItem.kt
│ │ │ │ │ ├── SettingsNavigation.kt
│ │ │ │ │ ├── SettingsViewModel.kt
│ │ │ │ │ ├── BooleanPreference.kt
│ │ │ │ │ └── SelectPreference.kt
│ │ │ │ ├── events
│ │ │ │ │ ├── EventsNavigation.kt
│ │ │ │ │ └── EventsViewModel.kt
│ │ │ │ ├── theme
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── AppViewModel.kt
│ │ │ │ ├── hotkeylist
│ │ │ │ │ ├── HotkeyListNavigation.kt
│ │ │ │ │ ├── HotkeyListViewModel.kt
│ │ │ │ │ └── ListDragState.kt
│ │ │ │ ├── home
│ │ │ │ │ ├── HomeNavigation.kt
│ │ │ │ │ ├── MediaBar.kt
│ │ │ │ │ └── HomeViewModel.kt
│ │ │ │ ├── SoundRemoteApp.kt
│ │ │ │ ├── AppNavigation.kt
│ │ │ │ └── hotkey
│ │ │ │ │ ├── HotkeyNavigation.kt
│ │ │ │ │ └── HotkeyViewModel.kt
│ │ │ │ └── MainActivity.kt
│ │ ├── assets
│ │ │ └── opus_license.txt
│ │ └── AndroidManifest.xml
│ ├── androidTest
│ │ └── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── soundremote
│ │ │ ├── Util.kt
│ │ │ ├── CustomTestRunner.kt
│ │ │ ├── data
│ │ │ ├── preferences
│ │ │ │ └── TestDataStoreModule.kt
│ │ │ ├── DatabaseResource.kt
│ │ │ ├── MigrationTest.kt
│ │ │ └── AppDatabaseTest.kt
│ │ │ ├── util
│ │ │ └── KeysTest.kt
│ │ │ ├── ui
│ │ │ ├── settings
│ │ │ │ └── SelectPreferenceTest.kt
│ │ │ └── hotkey
│ │ │ │ └── HotkeyFlowTest.kt
│ │ │ └── audio
│ │ │ └── decoder
│ │ │ └── OpusAudioDecoderTest.kt
│ └── test
│ │ └── java
│ │ └── io
│ │ └── github
│ │ └── soundremote
│ │ ├── Helpers.kt
│ │ ├── network
│ │ ├── AckConnectDataTest.kt
│ │ ├── KeepAliveDataTest.kt
│ │ ├── DisconnectDataTest.kt
│ │ ├── AckDataTest.kt
│ │ ├── HotkeyDataTest.kt
│ │ ├── SetFormatDataTest.kt
│ │ └── ConnectDataTest.kt
│ │ ├── MainDispatcherExtension.kt
│ │ ├── service
│ │ └── TestServiceManager.kt
│ │ ├── data
│ │ ├── HotkeyTest.kt
│ │ ├── TestEventActionRepository.kt
│ │ ├── TestHotkeyRepository.kt
│ │ └── preferences
│ │ │ └── TestPreferencesRepository.kt
│ │ └── ui
│ │ ├── SettingsViewModelTest.kt
│ │ ├── HotkeyListViewModelTest.kt
│ │ └── EventsViewModelTest.kt
├── proguard-rules.pro
├── schemas
│ └── io.github.soundremote.data.room.AppDatabase
│ │ ├── 2.json
│ │ ├── 3.json
│ │ └── 1.json
└── build.gradle.kts
├── metadata
└── en-US
│ ├── title.txt
│ ├── changelogs
│ ├── 10.txt
│ ├── 8.txt
│ ├── 9.txt
│ └── 12.txt
│ ├── short_description.txt
│ ├── images
│ ├── icon.png
│ └── phoneScreenshots
│ │ ├── 1.png
│ │ ├── 2.png
│ │ └── 3.png
│ └── full_description.txt
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── settings.gradle.kts
├── .gitignore
├── README.md
├── .github
└── workflows
│ └── android.yml
├── opus_license.txt
└── gradlew.bat
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 | /libs/
--------------------------------------------------------------------------------
/metadata/en-US/title.txt:
--------------------------------------------------------------------------------
1 | SoundRemote
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/10.txt:
--------------------------------------------------------------------------------
1 | Update metadata and Gradle configuration for publishing
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/8.txt:
--------------------------------------------------------------------------------
1 | - Added fastlane metadata
2 | - Minor bug fixes and optimizations
--------------------------------------------------------------------------------
/metadata/en-US/short_description.txt:
--------------------------------------------------------------------------------
1 | Listen to audio from your PC and control it from your phone
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/9.txt:
--------------------------------------------------------------------------------
1 | Fixed a bug where it was impossible to edit a previously added hotkey
--------------------------------------------------------------------------------
/metadata/en-US/images/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/metadata/en-US/images/icon.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher_plain-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/ic_launcher_plain-playstore.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/metadata/en-US/images/phoneScreenshots/1.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/metadata/en-US/images/phoneScreenshots/2.png
--------------------------------------------------------------------------------
/metadata/en-US/images/phoneScreenshots/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/metadata/en-US/images/phoneScreenshots/3.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_plain.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_plain.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_plain.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_plain.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_plain.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_plain.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_plain.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_plain.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_plain.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_plain.webp
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2 | android.useAndroidX=true
3 | kotlin.code.style=official
4 | org.gradle.configuration-cache=true
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_plain_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_plain_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_plain_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_plain_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_plain_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_plain_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_plain_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_plain_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_plain_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SoundRemote/client-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_plain_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values-night/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #212121
4 |
--------------------------------------------------------------------------------
/metadata/en-US/changelogs/12.txt:
--------------------------------------------------------------------------------
1 | - Added a preference that allows to play audio simultaneously with other apps
2 | - UI changes
3 | - Bug fixes and optimizations
4 | - Updated dependencies
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/audio/decoder/DecoderException.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.audio.decoder
2 |
3 | internal class DecoderException(message: String) : RuntimeException(message)
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/util/TestTag.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.util
2 |
3 | object TestTag {
4 | const val FAVOURITE_SWITCH = "favourite_switch"
5 | const val NAVIGATION_MENU = "navigation_menu"
6 | const val INPUT_FIELD = "input_field"
7 | }
8 |
--------------------------------------------------------------------------------
/metadata/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Listen to audio from your PC.
2 |
3 | Control your PC by executing hotkeys remotely. For example, you can:
4 |
5 | - Skip to the next track by shaking your phone
6 | - Pause video from lock screen
7 | - Add and execute custom hotkeys
8 |
9 | Get Windows server at soundremote.github.io
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/DisconnectData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import java.nio.ByteBuffer
4 |
5 | class DisconnectData : PacketData {
6 | override fun write(dest: ByteBuffer) {}
7 |
8 | companion object {
9 | const val SIZE = 0
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/KeepAliveData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import java.nio.ByteBuffer
4 |
5 | class KeepAliveData : PacketData {
6 | override fun write(dest: ByteBuffer) {}
7 |
8 | companion object {
9 | const val SIZE = 0
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 | #FAFAFA
5 |
6 | #00A173
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/util/Settings.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.util
2 |
3 | const val DEFAULT_SERVER_PORT = 15711
4 | const val DEFAULT_CLIENT_PORT = 15712
5 | const val DEFAULT_SERVER_ADDRESS = "192.168.0.100"
6 | const val DEFAULT_AUDIO_COMPRESSION = Net.COMPRESSION_192
7 | const val DEFAULT_IGNORE_AUDIO_FOCUS = false
8 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_plain_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_stop.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/Util.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.ui.test.junit4.AndroidComposeTestRule
5 | import kotlin.properties.ReadOnlyProperty
6 |
7 | fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
8 | ReadOnlyProperty { _, _ -> activity.getString(resId) }
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_skip_next.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_skip_previous.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionSha256Sum=61ad310d3c7d3e5da131b76bbf22b5a4c0786e9d892dae8c1658d4b484de3caa
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_play_pause.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_close.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_volume_down.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/SoundRemoteApplication.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | import timber.log.Timber
6 |
7 | @HiltAndroidApp
8 | class SoundRemoteApplication : Application() {
9 | override fun onCreate() {
10 | super.onCreate()
11 |
12 | if (BuildConfig.DEBUG) {
13 | Timber.plant(Timber.DebugTree())
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/service/ServiceModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.service
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 |
8 | @Module
9 | @InstallIn(SingletonComponent::class)
10 | internal interface ServiceModule {
11 |
12 | @Binds
13 | fun bindsServiceManager(
14 | serviceManager: MainServiceManager,
15 | ): ServiceManager
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_volume_mute.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/PacketData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import java.nio.ByteBuffer
4 |
5 | interface PacketData {
6 |
7 | /**
8 | * Writes this packet data to the given ByteBuffer and increments its position by packet size.
9 | * @param dest [ByteBuffer] to write to.
10 | * @throws IllegalArgumentException if there are fewer than packet size bytes remaining in [dest].
11 | */
12 | fun write(dest: ByteBuffer)
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/EventActionRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface EventActionRepository {
6 | suspend fun getById(id: Int): EventAction?
7 |
8 | suspend fun insert(eventAction: EventAction)
9 |
10 | suspend fun update(eventAction: EventAction): Int
11 |
12 | suspend fun deleteById(id: Int)
13 |
14 | fun getAll(): Flow>
15 |
16 | fun getShakeEventFlow(): Flow
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/CustomTestRunner.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import android.app.Application
4 | import android.content.Context
5 | import androidx.test.runner.AndroidJUnitRunner
6 | import dagger.hilt.android.testing.HiltTestApplication
7 |
8 | class CustomTestRunner : AndroidJUnitRunner() {
9 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
10 | return super.newApplication(cl, HiltTestApplication::class.java.name, context)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_volume_up.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | maven {
14 | url = uri("https://www.jitpack.io")
15 | content { includeGroup("com.github.ashipo") }
16 | }
17 | }
18 | }
19 | rootProject.name = "soundremote"
20 | include(":app")
21 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/about/AboutNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.about
2 |
3 | import androidx.navigation.NavController
4 | import androidx.navigation.NavGraphBuilder
5 | import androidx.navigation.compose.composable
6 | import kotlinx.serialization.Serializable
7 |
8 | @Serializable
9 | object AboutRoute
10 |
11 | fun NavController.navigateToAbout() {
12 | navigate(AboutRoute)
13 | }
14 |
15 | fun NavGraphBuilder.aboutScreen(
16 | onNavigateUp: () -> Unit,
17 | ) {
18 | composable {
19 | AboutScreen(
20 | onNavigateUp = onNavigateUp,
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/EventAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import android.provider.BaseColumns
4 | import androidx.room.ColumnInfo
5 | import androidx.room.Embedded
6 | import androidx.room.Entity
7 | import androidx.room.PrimaryKey
8 |
9 | @Entity(tableName = EventAction.TABLE_NAME)
10 | data class EventAction(
11 | @ColumnInfo(name = COLUMN_ID)
12 | @PrimaryKey
13 | var eventId: Int,
14 |
15 | @Embedded
16 | var action: ActionData
17 | ) {
18 | companion object {
19 | const val TABLE_NAME = "event_action"
20 | const val COLUMN_ID = BaseColumns._ID
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/Helpers.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import io.github.soundremote.data.Hotkey
4 | import io.github.soundremote.util.KeyCode
5 | import io.github.soundremote.util.Mods
6 |
7 | /**
8 | * Creates a [Hotkey] with the specified parameters. Other parameters will have arbitrary default
9 | * values.
10 | */
11 | fun getHotkey(
12 | id: Int = 0,
13 | keyCode: KeyCode = KeyCode(0x42),
14 | mods: Mods = Mods(),
15 | name: String = "Hotkey name",
16 | favoured: Boolean = false,
17 | order: Int = 0,
18 | ): Hotkey {
19 | return Hotkey(id, keyCode, mods, name, favoured, order)
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.apk
16 | output.json
17 |
18 | # IntelliJ
19 | *.iml
20 | .idea/
21 | misc.xml
22 | deploymentTargetDropDown.xml
23 | render.experimental.xml
24 |
25 | # Keystore files
26 | *.jks
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
34 |
35 | # Kotlin data
36 | .kotlin
37 |
38 | # Signing info
39 | keystore.properties
40 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/components/ListItemHeadline.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.components
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.Text
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.text.style.TextOverflow
8 |
9 | @Composable
10 | internal fun ListItemHeadline(
11 | text: String,
12 | modifier: Modifier = Modifier,
13 | ) {
14 | Text(
15 | text = text,
16 | style = MaterialTheme.typography.bodyLarge,
17 | maxLines = 1,
18 | overflow = TextOverflow.Ellipsis,
19 | modifier = modifier,
20 | )
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/components/NavigateUpButton.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.components
2 |
3 | import androidx.compose.material.icons.Icons
4 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
5 | import androidx.compose.material3.Icon
6 | import androidx.compose.material3.IconButton
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.res.stringResource
9 | import io.github.soundremote.R
10 |
11 | @Composable
12 | fun NavigateUpButton(
13 | onClick: () -> Unit,
14 | ) {
15 | IconButton(onClick = onClick) {
16 | Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/ActionType.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import androidx.annotation.StringRes
4 | import io.github.soundremote.R
5 |
6 | enum class ActionType(
7 | val id: Int,
8 | @StringRes
9 | val nameStringId: Int,
10 | ) {
11 | APP(1, R.string.action_type_app),
12 | HOTKEY(2, R.string.action_type_hotkey);
13 |
14 | companion object {
15 | /**
16 | * Get [ActionType] by its id.
17 | * @throws [NoSuchElementException] if no entry with such id is found.
18 | */
19 | fun getById(id: Int): ActionType {
20 | return ActionType.entries.first { it.id == id }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/HotkeyRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface HotkeyRepository {
6 | suspend fun getById(id: Int): Hotkey?
7 |
8 | suspend fun insert(hotkey: Hotkey): Long
9 |
10 | suspend fun update(hotkey: Hotkey): Int
11 |
12 | suspend fun deleteById(id: Int)
13 |
14 | suspend fun changeFavoured(id: Int, favoured: Boolean)
15 |
16 | fun getFavouredOrdered(favoured: Boolean): Flow>
17 |
18 | fun getAllOrdered(): Flow>
19 |
20 | fun getAllInfoOrdered(): Flow>
21 |
22 | suspend fun updateOrders(hotkeyOrders: List)
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/components/ListItemSupport.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.components
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.Text
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.text.style.TextOverflow
8 |
9 | @Composable
10 | internal fun ListItemSupport(
11 | text: String,
12 | modifier: Modifier = Modifier,
13 | ) {
14 | Text(
15 | text = text,
16 | style = MaterialTheme.typography.bodyMedium,
17 | color = MaterialTheme.colorScheme.onSurfaceVariant,
18 | overflow = TextOverflow.Ellipsis,
19 | modifier = modifier,
20 | )
21 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_notification.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/Action.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import android.os.Parcelable
4 | import androidx.room.ColumnInfo
5 | import androidx.room.Ignore
6 | import kotlinx.parcelize.Parcelize
7 |
8 | data class ActionData(
9 | @ColumnInfo(name = COLUMN_TYPE, defaultValue = "0")
10 | var actionType: Int,
11 |
12 | @ColumnInfo(name = COLUMN_ID)
13 | var actionId: Int,
14 | ) {
15 | @Ignore
16 | constructor(actionType: ActionType, actionId: Int) : this(actionType.id, actionId)
17 |
18 | companion object {
19 | const val COLUMN_TYPE = "action_type"
20 | const val COLUMN_ID = "action_id"
21 | }
22 | }
23 |
24 | @Parcelize
25 | data class Action(val type: ActionType, val id: Int) : Parcelable
26 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/util/Audio.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.util
2 |
3 | import android.media.AudioFormat
4 | import io.github.soundremote.audio.sink.MIN_PCM_BUFFER_DURATION
5 |
6 | object Audio {
7 | const val SAMPLE_RATE = 48_000
8 | const val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_STEREO
9 | const val CHANNELS = 2
10 | const val SAMPLE_ENCODING = AudioFormat.ENCODING_PCM_16BIT
11 |
12 | /** Sample size in bytes */
13 | const val SAMPLE_SIZE = 2
14 |
15 | /** Packet duration in microseconds */
16 | const val PACKET_DURATION = 10_000
17 |
18 | /** The limit on number of packets lost in a row that should be attempted to conceal */
19 | const val PACKET_CONCEAL_LIMIT = MIN_PCM_BUFFER_DURATION * 1_000 / PACKET_DURATION
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/room/BaseDao.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.room
2 |
3 | import androidx.room.Delete
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Update
7 |
8 | interface BaseDao {
9 | @Insert(onConflict = OnConflictStrategy.REPLACE)
10 | suspend fun insert(entity: T): Long
11 |
12 | @Insert(onConflict = OnConflictStrategy.REPLACE)
13 | suspend fun insertAll(vararg entity: T)
14 |
15 | @Insert(onConflict = OnConflictStrategy.REPLACE)
16 | suspend fun insertAll(entities: Collection)
17 |
18 | @Update(onConflict = OnConflictStrategy.REPLACE)
19 | suspend fun update(vararg entities: T): Int
20 |
21 | @Delete
22 | suspend fun delete(vararg entities: T): Int
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/HotkeyData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net.putUByte
4 | import io.github.soundremote.util.PacketKeyType
5 | import io.github.soundremote.util.PacketModsType
6 | import java.nio.ByteBuffer
7 |
8 | data class HotkeyData(val keyCode: PacketKeyType, val mods: PacketModsType) : PacketData {
9 |
10 | override fun write(dest: ByteBuffer) {
11 | require(dest.remaining() >= SIZE)
12 | dest.putUByte(keyCode)
13 | dest.putUByte(mods)
14 | }
15 |
16 | companion object {
17 | /**
18 | * unsigned 8bit Virtual-key code
19 | *
20 | * unsigned 8bit Bit field of the mod keys
21 | */
22 | const val SIZE = 2
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/AppAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import androidx.annotation.StringRes
4 | import io.github.soundremote.R
5 |
6 | internal enum class AppAction(
7 | val id: Int,
8 | @StringRes
9 | val nameStringId: Int,
10 | ) {
11 | MUTE(1, R.string.app_action_mute),
12 | UNMUTE(2, R.string.app_action_unmute),
13 | CONNECT(3, R.string.app_action_connect),
14 | DISCONNECT(4, R.string.app_action_disconnect);
15 |
16 | companion object {
17 | /**
18 | * Get enum entry by its id.
19 | * @throws [NoSuchElementException] if no entry with such id is found.
20 | */
21 | fun getById(id: Int): AppAction {
22 | return entries.first { it.id == id }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/SetFormatData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUByte
5 | import io.github.soundremote.util.Net.putUShort
6 | import io.github.soundremote.util.PacketRequestIdType
7 | import java.nio.ByteBuffer
8 |
9 | data class SetFormatData(
10 | @Net.Compression val compression: Int,
11 | val requestId: PacketRequestIdType
12 | ) : PacketData {
13 | override fun write(dest: ByteBuffer) {
14 | require(dest.remaining() >= SIZE)
15 | dest.putUShort(requestId)
16 | dest.putUByte(compression.toUByte())
17 | }
18 |
19 | companion object {
20 | /**
21 | * unsigned 16bit Request id
22 | *
23 | * unsigned 8bit Compression
24 | */
25 | const val SIZE = 3
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/room/EventActionDao.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Query
5 | import io.github.soundremote.data.EventAction
6 | import kotlinx.coroutines.flow.Flow
7 |
8 | @Dao
9 | interface EventActionDao : BaseDao {
10 |
11 | @Query("SELECT * FROM ${EventAction.TABLE_NAME} WHERE ${EventAction.COLUMN_ID} = :id")
12 | suspend fun getById(id: Int): EventAction?
13 |
14 | @Query("DELETE FROM ${EventAction.TABLE_NAME} WHERE ${EventAction.COLUMN_ID} = :id")
15 | suspend fun deleteById(id: Int)
16 |
17 | @Query("SELECT * FROM ${EventAction.TABLE_NAME}")
18 | fun getAll(): Flow>
19 |
20 | @Query("SELECT * FROM ${EventAction.TABLE_NAME} WHERE ${EventAction.COLUMN_ID} = :eventId")
21 | fun getEventActionFlow(eventId: Int): Flow
22 | }
23 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/RepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import dagger.Binds
4 | import dagger.Module
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.components.SingletonComponent
7 | import io.github.soundremote.data.preferences.PreferencesRepository
8 | import io.github.soundremote.data.preferences.UserPreferencesRepository
9 |
10 | @Module
11 | @InstallIn(SingletonComponent::class)
12 | interface RepositoryModule {
13 |
14 | @Binds
15 | fun bindsPreferencesRepository(
16 | preferencesRepository: UserPreferencesRepository,
17 | ): PreferencesRepository
18 |
19 | @Binds
20 | fun bindsHotkeyRepository(
21 | hotkeyRepository: UserHotkeyRepository,
22 | ): HotkeyRepository
23 |
24 | @Binds
25 | fun bindsEventActionRepository(
26 | eventActionRepository: SystemEventActionRepository,
27 | ): EventActionRepository
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/data/preferences/TestDataStoreModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.preferences
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory
5 | import androidx.datastore.preferences.core.Preferences
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.components.SingletonComponent
9 | import dagger.hilt.testing.TestInstallIn
10 | import org.junit.rules.TemporaryFolder
11 | import javax.inject.Singleton
12 |
13 | @TestInstallIn(
14 | components = [SingletonComponent::class],
15 | replaces = [DataStoreModule::class],
16 | )
17 | @Module
18 | object TestDataStoreModule {
19 | @Singleton
20 | @Provides
21 | fun providePreferencesDatastore(tmpFolder: TemporaryFolder): DataStore =
22 | PreferenceDataStoreFactory.create { tmpFolder.newFile("test.preferences_pb") }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/ConnectData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUByte
5 | import io.github.soundremote.util.Net.putUShort
6 | import io.github.soundremote.util.PacketRequestIdType
7 | import java.nio.ByteBuffer
8 |
9 | data class ConnectData(@Net.Compression val compression: Int, val requestId: PacketRequestIdType) :
10 | PacketData {
11 | override fun write(dest: ByteBuffer) {
12 | require(dest.remaining() >= SIZE)
13 | dest.putUByte(Net.PROTOCOL_VERSION)
14 | dest.putUShort(requestId)
15 | dest.putUByte(compression.toUByte())
16 | }
17 |
18 | companion object {
19 | /**
20 | * unsigned 8bit Protocol version
21 | *
22 | * unsigned 16bit Request id
23 | *
24 | * unsigned 8bit Compression
25 | */
26 | const val SIZE = 4
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/AckConnectDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUByte
5 | import io.github.soundremote.util.PacketProtocolType
6 | import org.junit.jupiter.api.Assertions
7 | import org.junit.jupiter.api.DisplayName
8 | import org.junit.jupiter.api.Test
9 |
10 | @DisplayName("AckConnectData")
11 | internal class AckConnectDataTest {
12 | @DisplayName("Reads correctly")
13 | @Test
14 | fun read_ReadsCorrectly() {
15 | val expected: PacketProtocolType = 0xFDu
16 | val buffer = Net.createPacketBuffer(AckData.CUSTOM_DATA_SIZE)
17 | .putUByte(expected)
18 | buffer.rewind()
19 |
20 | val ackConnectData = AckConnectData.read(buffer)
21 | val actual = ackConnectData?.protocol
22 |
23 | Assertions.assertNotNull(actual)
24 | Assertions.assertEquals(expected, actual!!)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/service/ServiceManager.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.service
2 |
3 | import android.content.Context
4 | import io.github.soundremote.data.Hotkey
5 | import io.github.soundremote.util.ConnectionStatus
6 | import io.github.soundremote.util.Key
7 | import io.github.soundremote.util.SystemMessage
8 | import kotlinx.coroutines.channels.ReceiveChannel
9 | import kotlinx.coroutines.flow.StateFlow
10 |
11 | internal interface ServiceManager {
12 | val serviceState: StateFlow
13 | val systemMessages: ReceiveChannel
14 | fun bind(context: Context)
15 | fun unbind(context: Context)
16 | fun connect(address: String)
17 | fun disconnect()
18 | fun sendHotkey(hotkey: Hotkey)
19 | fun sendKey(key: Key)
20 | fun setMuted(value: Boolean)
21 | }
22 |
23 | data class ServiceState(
24 | val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
25 | val isMuted: Boolean = false,
26 | )
27 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/settings/PreferenceItem.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.unit.dp
10 | import io.github.soundremote.ui.components.ListItemHeadline
11 | import io.github.soundremote.ui.components.ListItemSupport
12 |
13 | @Composable
14 | internal fun PreferenceItem(
15 | title: String,
16 | summary: String,
17 | onClick: () -> Unit,
18 | modifier: Modifier = Modifier,
19 | ) {
20 | Column(
21 | modifier
22 | .fillMaxWidth()
23 | .clickable(onClick = onClick)
24 | .padding(horizontal = 16.dp, vertical = 8.dp)
25 | ) {
26 | ListItemHeadline(title)
27 | ListItemSupport(summary)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/KeepAliveDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import org.junit.jupiter.api.Assertions.assertEquals
5 | import org.junit.jupiter.api.DisplayName
6 | import org.junit.jupiter.api.Test
7 |
8 | @DisplayName("KeepAliveData")
9 | class KeepAliveDataTest {
10 | @DisplayName("SIZE has correct value")
11 | @Test
12 | fun size_ReturnsCorrectValue() {
13 | val expected = 0
14 |
15 | val actual = KeepAliveData.SIZE
16 |
17 | assertEquals(expected, actual)
18 | }
19 |
20 | @DisplayName("write() writes correctly")
21 | @Test
22 | fun write_WritesCorrectly() {
23 | val keepAliveData = KeepAliveData()
24 | val expected = Net.createPacketBuffer(KeepAliveData.SIZE)
25 | expected.rewind()
26 |
27 | val actual = Net.createPacketBuffer(KeepAliveData.SIZE)
28 | keepAliveData.write(actual)
29 | actual.rewind()
30 |
31 | assertEquals(expected, actual)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/DisconnectDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import org.junit.jupiter.api.Assertions.assertEquals
5 | import org.junit.jupiter.api.DisplayName
6 | import org.junit.jupiter.api.Test
7 |
8 | @DisplayName("DisconnectData")
9 | internal class DisconnectDataTest {
10 | @DisplayName("SIZE has correct value")
11 | @Test
12 | fun size_ReturnsCorrectValue() {
13 | val expected = 0
14 |
15 | val actual = DisconnectData.SIZE
16 |
17 | assertEquals(expected, actual)
18 | }
19 |
20 | @DisplayName("write() writes correctly")
21 | @Test
22 | fun write_WritesCorrectly() {
23 | val disconnectData = DisconnectData()
24 | val expected = Net.createPacketBuffer(DisconnectData.SIZE)
25 | expected.rewind()
26 |
27 | val actual = Net.createPacketBuffer(DisconnectData.SIZE)
28 | disconnectData.write(actual)
29 | actual.rewind()
30 |
31 | assertEquals(expected, actual)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/preferences/PreferencesRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.preferences
2 |
3 | import kotlinx.coroutines.flow.Flow
4 |
5 | interface PreferencesRepository {
6 |
7 | val settingsScreenPreferencesFlow: Flow
8 |
9 | /**
10 | * Recent server addresses, from the oldest to the most recent
11 | */
12 | val serverAddressesFlow: Flow>
13 |
14 | val audioCompressionFlow: Flow
15 |
16 | val ignoreAudioFocusFlow: Flow
17 |
18 | suspend fun setServerAddress(serverAddress: String)
19 |
20 | suspend fun getServerAddress(): String
21 |
22 | suspend fun setServerPort(value: Int)
23 |
24 | suspend fun getServerPort(): Int
25 |
26 | suspend fun setClientPort(value: Int)
27 |
28 | suspend fun getClientPort(): Int
29 |
30 | suspend fun setAudioCompression(value: Int)
31 |
32 | suspend fun getAudioCompression(): Int
33 |
34 | suspend fun setIgnoreAudioFocus(value: Boolean)
35 |
36 | suspend fun getIgnoreAudioFocus(): Boolean
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/MainDispatcherExtension.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.TestDispatcher
6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.jupiter.api.extension.AfterEachCallback
10 | import org.junit.jupiter.api.extension.BeforeEachCallback
11 | import org.junit.jupiter.api.extension.ExtensionContext
12 |
13 | // https://developer.android.com/kotlin/coroutines/test#setting-main-dispatcher
14 | @OptIn(ExperimentalCoroutinesApi::class)
15 | class MainDispatcherExtension constructor(
16 | private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
17 | ) : BeforeEachCallback, AfterEachCallback {
18 | override fun beforeEach(p0: ExtensionContext?) {
19 | Dispatchers.setMain(testDispatcher)
20 | }
21 |
22 | override fun afterEach(p0: ExtensionContext?) {
23 | Dispatchers.resetMain()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/AckConnectData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net.uByte
4 | import io.github.soundremote.util.PacketProtocolType
5 | import java.nio.ByteBuffer
6 |
7 | /**
8 | * Custom data for ACK response on a Connect request.
9 | */
10 | data class AckConnectData(val protocol: PacketProtocolType) {
11 |
12 | companion object {
13 | /*
14 | unsigned 8bit protocol version
15 | */
16 | private const val SIZE = 1
17 |
18 | /**
19 | * Read ACK packet custom data from the source [ByteBuffer].
20 | * Increments [source] position by [SIZE] on successful read.
21 | * @param source [ByteBuffer] to read from
22 | * @return [AckData] instance or null if there is not enough data remaining in [source].
23 | */
24 | fun read(source: ByteBuffer): AckConnectData? {
25 | if (source.remaining() < SIZE) return null
26 | val protocol = source.uByte
27 | return AckConnectData(protocol)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SoundRemote android client
2 |
3 | An Android app that, when paired up with [SoundRemote server](https://github.com/SoundRemote/server-windows), allows to:
4 |
5 | - Capture and stream audio from a PC to an Android device
6 | - Execute keyboard commands on the PC remotely from the Android app either directly through its UI or by binding to certain events such as device shaking or incoming phone call
7 | - Control media on the PC through the Android media notification
8 |
9 | [
](https://f-droid.org/packages/io.github.soundremote/)
12 |
13 | Or download the latest APK from the [Releases Section](https://github.com/SoundRemote/client-android/releases/latest).
14 |
15 | ## Screenshots
16 |
17 |
⠀
18 |
⠀
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/events/EventsNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.events
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.hilt.navigation.compose.hiltViewModel
5 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.compose.composable
9 | import kotlinx.serialization.Serializable
10 |
11 | @Serializable
12 | object EventsRoute
13 |
14 | fun NavController.navigateToEvents() {
15 | navigate(EventsRoute)
16 | }
17 |
18 | fun NavGraphBuilder.eventsScreen(
19 | onNavigateUp: () -> Unit,
20 | ) {
21 | composable {
22 | val viewModel: EventsViewModel = hiltViewModel()
23 | val eventsUIState by viewModel.uiState.collectAsStateWithLifecycle()
24 | EventsScreen(
25 | eventsUIState = eventsUIState,
26 | onSetActionForEvent = { eventId, action ->
27 | viewModel.setActionForEvent(eventId, action)
28 | },
29 | onNavigateUp = onNavigateUp,
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/AckDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUShort
5 | import io.github.soundremote.util.PacketRequestIdType
6 | import org.junit.jupiter.api.Assertions
7 | import org.junit.jupiter.api.DisplayName
8 | import org.junit.jupiter.api.Test
9 |
10 | @DisplayName("AckData")
11 | internal class AckDataTest {
12 | @DisplayName("Reads correctly")
13 | @Test
14 | fun read_ReadsCorrectly() {
15 | val requestId: PacketRequestIdType = 0xFAAFu
16 | val customData = 0xFA123456.toInt()
17 | val expectedCustomData = Net.createPacketBuffer(AckData.CUSTOM_DATA_SIZE)
18 | .putInt(customData)
19 | expectedCustomData.rewind()
20 | val expected = AckData(requestId, expectedCustomData)
21 | val packet = Net.createPacketBuffer(20)
22 | .putUShort(requestId)
23 | .putInt(customData)
24 | packet.rewind()
25 |
26 | val actual = AckData.read(packet)
27 |
28 | Assertions.assertNotNull(actual)
29 | Assertions.assertEquals(expected, actual!!)
30 | }
31 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.theme
2 |
3 | import android.os.Build
4 | import androidx.compose.foundation.isSystemInDarkTheme
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.material3.darkColorScheme
7 | import androidx.compose.material3.dynamicDarkColorScheme
8 | import androidx.compose.material3.dynamicLightColorScheme
9 | import androidx.compose.material3.lightColorScheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | @Composable
14 | fun SoundRemoteTheme(
15 | darkTheme: Boolean = isSystemInDarkTheme(),
16 | dynamicColor: Boolean = true,
17 | content: @Composable () -> Unit
18 | ) {
19 | val colorScheme = when {
20 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
21 | val context = LocalContext.current
22 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
23 | }
24 |
25 | darkTheme -> darkColorScheme()
26 | else -> lightColorScheme()
27 | }
28 | MaterialTheme(
29 | colorScheme = colorScheme,
30 | content = content
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/data/DatabaseResource.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import androidx.test.core.app.ApplicationProvider
6 | import io.github.soundremote.data.room.AppDatabase
7 | import kotlinx.coroutines.CoroutineDispatcher
8 | import org.junit.rules.ExternalResource
9 |
10 | internal class DatabaseResource(private val dispatcher: CoroutineDispatcher) : ExternalResource() {
11 | private lateinit var db: AppDatabase
12 | lateinit var eventActionRepository: SystemEventActionRepository
13 | private set
14 | lateinit var hotkeyRepository: UserHotkeyRepository
15 | private set
16 |
17 | override fun before() {
18 | val context = ApplicationProvider.getApplicationContext()
19 | db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
20 | .addCallback(AppDatabase.Callback())
21 | .build()
22 | eventActionRepository = SystemEventActionRepository(db.eventActionDao(), dispatcher)
23 | hotkeyRepository = UserHotkeyRepository(db.hotkeyDao(), dispatcher)
24 | }
25 |
26 | override fun after() {
27 | db.close()
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/AckData.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.uShort
5 | import io.github.soundremote.util.PacketRequestIdType
6 | import java.nio.ByteBuffer
7 |
8 | data class AckData(val requestId: PacketRequestIdType, val customData: ByteBuffer) {
9 |
10 | companion object {
11 | const val CUSTOM_DATA_SIZE = 4
12 |
13 | /*
14 | unsigned 16bit request id
15 | 4 byte custom data
16 | */
17 | const val SIZE = 6
18 |
19 | /**
20 | * Read ACK packet data from the source [ByteBuffer].
21 | * Increments [source] position by [SIZE] on successful read.
22 | * @param source [ByteBuffer] to read from
23 | * @return [AckData] instance or null if there is not enough data remaining in [source].
24 | */
25 | fun read(source: ByteBuffer): AckData? {
26 | if (source.remaining() < SIZE) return null
27 | val id = source.uShort
28 | val customData = Net.createPacketBuffer(CUSTOM_DATA_SIZE)
29 | source.get(customData.array())
30 | return AckData(id, customData)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/settings/SettingsNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.hilt.navigation.compose.hiltViewModel
5 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.compose.composable
9 | import kotlinx.serialization.Serializable
10 |
11 | @Serializable
12 | object SettingsRoute
13 |
14 | fun NavController.navigateToSettings() {
15 | navigate(SettingsRoute)
16 | }
17 |
18 | fun NavGraphBuilder.settingsScreen(
19 | onNavigateUp: () -> Unit,
20 | ) {
21 | composable {
22 | val viewModel: SettingsViewModel = hiltViewModel()
23 | val settings by viewModel.settings.collectAsStateWithLifecycle()
24 | SettingsScreen(
25 | settings = settings,
26 | onSetServerPort = viewModel::setServerPort,
27 | onSetClientPort = viewModel::setClientPort,
28 | onSetAudioCompression = viewModel::setAudioCompression,
29 | onSetIgnoreAudioFocus = viewModel::setIgnoreAudioFocus,
30 | onNavigateUp = onNavigateUp,
31 | )
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/DatabaseModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import android.content.Context
4 | import androidx.room.Room.databaseBuilder
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import dagger.hilt.components.SingletonComponent
10 | import io.github.soundremote.data.room.AppDatabase
11 | import io.github.soundremote.data.room.EventActionDao
12 | import io.github.soundremote.data.room.HotkeyDao
13 | import javax.inject.Singleton
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object DatabaseModule {
18 |
19 | @Provides
20 | @Singleton
21 | fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
22 | return databaseBuilder(appContext, AppDatabase::class.java, "sound_remote")
23 | .addCallback(AppDatabase.Callback())
24 | .build()
25 | }
26 |
27 | @Provides
28 | @Singleton
29 | fun provideHotkeyDao(database: AppDatabase): HotkeyDao {
30 | return database.hotkeyDao()
31 | }
32 |
33 | @Provides
34 | @Singleton
35 | fun provideEventActionDao(database: AppDatabase): EventActionDao {
36 | return database.eventActionDao()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/preferences/DataStoreModule.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.preferences
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataStore
5 | import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
6 | import androidx.datastore.preferences.core.PreferenceDataStoreFactory
7 | import androidx.datastore.preferences.core.Preferences
8 | import androidx.datastore.preferences.core.emptyPreferences
9 | import androidx.datastore.preferences.preferencesDataStoreFile
10 | import dagger.Module
11 | import dagger.Provides
12 | import dagger.hilt.InstallIn
13 | import dagger.hilt.android.qualifiers.ApplicationContext
14 | import dagger.hilt.components.SingletonComponent
15 | import javax.inject.Singleton
16 |
17 | private const val USER_PREFERENCES_NAME = "user_preferences"
18 |
19 | @Module
20 | @InstallIn(SingletonComponent::class)
21 | object DataStoreModule {
22 |
23 | @Singleton
24 | @Provides
25 | fun providePreferencesDatastore(@ApplicationContext appContext: Context): DataStore =
26 | PreferenceDataStoreFactory.create(
27 | corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
28 | produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) }
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/HotkeyDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUByte
5 | import io.github.soundremote.util.PacketKeyType
6 | import io.github.soundremote.util.PacketModsType
7 | import org.junit.jupiter.api.Assertions
8 | import org.junit.jupiter.api.DisplayName
9 | import org.junit.jupiter.api.Test
10 |
11 | @DisplayName("HotkeyData")
12 | internal class HotkeyDataTest {
13 |
14 | @DisplayName("SIZE has correct value")
15 | @Test
16 | fun size_ReturnsCorrectValue() {
17 | val expected = 2
18 |
19 | val actual = HotkeyData.SIZE
20 |
21 | Assertions.assertEquals(expected, actual)
22 | }
23 |
24 | @DisplayName("write() writes correctly")
25 | @Test
26 | fun write_WritesCorrectly() {
27 | val key: PacketKeyType = 0xDDu
28 | val mods: PacketModsType = 0x15u
29 | val expected = Net.createPacketBuffer(HotkeyData.SIZE)
30 | expected.putUByte(key)
31 | expected.putUByte(mods)
32 | expected.rewind()
33 |
34 | val actual = Net.createPacketBuffer(HotkeyData.SIZE)
35 | HotkeyData(key, mods).write(actual)
36 | actual.rewind()
37 |
38 | Assertions.assertEquals(expected, actual)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/room/DatabaseMigrations.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.room
2 |
3 | import androidx.room.RenameColumn
4 | import androidx.room.RenameTable
5 | import androidx.room.migration.AutoMigrationSpec
6 | import androidx.sqlite.db.SupportSQLiteDatabase
7 |
8 | object DatabaseMigrations {
9 | @RenameColumn(
10 | tableName = "event_action",
11 | fromColumnName = "keystroke_id",
12 | toColumnName = "action_id",
13 | )
14 | class Schema1to2 : AutoMigrationSpec {
15 | override fun onPostMigrate(db: SupportSQLiteDatabase) {
16 | // Set `action_type` field to `ActionType.KEYSTROKE.id` for all rows in `event_action`
17 | // table because keystroke was the only action type in the previous db version.
18 | db.execSQL("UPDATE event_action SET action_type = 2;")
19 | }
20 | }
21 |
22 | @RenameTable(
23 | fromTableName = "keystroke",
24 | toTableName = "hotkey",
25 | )
26 | class Schema2to3 : AutoMigrationSpec {
27 | override fun onPostMigrate(db: SupportSQLiteDatabase) {
28 | // Rename trigger
29 | db.execSQL("DROP TRIGGER IF EXISTS delete_event_action_on_keystroke_delete;")
30 | db.execSQL(CREATE_TRIGGER_DELETE_EVENT_ACTION_ON_HOTKEY_DELETE)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/components/HotkeySelectViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.components
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import io.github.soundremote.data.HotkeyRepository
6 | import io.github.soundremote.util.HotkeyDescription
7 | import io.github.soundremote.util.generateDescription
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.SharingStarted
10 | import kotlinx.coroutines.flow.map
11 | import kotlinx.coroutines.flow.stateIn
12 | import javax.inject.Inject
13 |
14 | data class HotkeyInfoUIState(
15 | val id: Int,
16 | val name: String,
17 | val description: HotkeyDescription,
18 | )
19 |
20 | @HiltViewModel
21 | class HotkeySelectViewModel @Inject constructor(
22 | hotkeyRepository: HotkeyRepository,
23 | ) : ViewModel() {
24 | val hotkeysState = hotkeyRepository.getAllInfoOrdered()
25 | .map { hotkeys ->
26 | hotkeys.map { hotkey ->
27 | HotkeyInfoUIState(
28 | hotkey.id,
29 | hotkey.name,
30 | description = generateDescription(hotkey),
31 | )
32 | }
33 | }.stateIn(
34 | scope = viewModelScope,
35 | started = SharingStarted.WhileSubscribed(5_000),
36 | initialValue = emptyList()
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_plain_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/Event.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import androidx.annotation.StringRes
4 | import io.github.soundremote.R
5 | import io.github.soundremote.util.AppPermission
6 |
7 | internal enum class Event(
8 | val id: Int,
9 | @StringRes
10 | val nameStringId: Int,
11 | val applicableActionTypes: Set,
12 | val requiredPermission: AppPermission?,
13 | // Min SDK version for permission to be requested for the event.
14 | // If == null permission will always be requested.
15 | val permissionMinSdk: Int?,
16 | ) {
17 | CALL_BEGIN(
18 | 1,
19 | R.string.event_name_call_start,
20 | setOf(ActionType.HOTKEY),
21 | AppPermission.Phone,
22 | 31,
23 | ),
24 | CALL_END(
25 | 2,
26 | R.string.event_name_call_end,
27 | setOf(ActionType.HOTKEY),
28 | AppPermission.Phone,
29 | 31,
30 | ),
31 | SHAKE(
32 | 100,
33 | R.string.event_name_shake,
34 | ActionType.entries.toSet(),
35 | null,
36 | null,
37 | );
38 |
39 | companion object {
40 | /**
41 | * Get [Event] by its id.
42 | * @throws [NoSuchElementException] if no entry with such id is found.
43 | */
44 | fun getById(id: Int): Event {
45 | return Event.entries.first { it.id == id }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout SoundRemote
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up JDK 17
17 | uses: actions/setup-java@v4
18 | with:
19 | java-version: '17'
20 | distribution: 'temurin'
21 | cache: gradle
22 |
23 | - name: Create keystore.jks
24 | env:
25 | KEYSTORE_JKS_BASE64: ${{ secrets.KEYSTORE_JKS_BASE64 }}
26 | run: |
27 | echo $KEYSTORE_JKS_BASE64 | base64 --decode > "${{ github.workspace }}/keystore.jks"
28 |
29 | - name: Create keystore.properties
30 | env:
31 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
32 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
33 | STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
34 | run: |
35 | echo -e "storeFile=${{ github.workspace }}/keystore.jks\nkeyAlias=$KEY_ALIAS\nkeyPassword=$KEY_PASSWORD\nstorePassword=$STORE_PASSWORD" > keystore.properties
36 |
37 | - name: Build SoundRemote
38 | run: |
39 | chmod +x ./gradlew
40 | ./gradlew --no-daemon build
41 |
42 | - name: Upload artifact
43 | uses: actions/upload-artifact@v4
44 | with:
45 | name: apk-release
46 | path: app/build/outputs/apk/release/*.apk
47 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/SetFormatDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.COMPRESSION_320
5 | import io.github.soundremote.util.Net.putUByte
6 | import io.github.soundremote.util.Net.putUShort
7 | import io.github.soundremote.util.PacketRequestIdType
8 | import org.junit.jupiter.api.Assertions
9 | import org.junit.jupiter.api.DisplayName
10 | import org.junit.jupiter.api.Test
11 |
12 | @DisplayName("SetFormatData")
13 | class SetFormatDataTest {
14 |
15 | @DisplayName("SIZE has correct value")
16 | @Test
17 | fun size_ReturnsCorrectValue() {
18 | val expected = 3
19 |
20 | val actual = SetFormatData.SIZE
21 |
22 | Assertions.assertEquals(expected, actual)
23 | }
24 |
25 | @DisplayName("write() writes correctly")
26 | @Test
27 | fun write_WritesCorrectly() {
28 | @Net.Compression val compression: Int = COMPRESSION_320
29 | val requestId: PacketRequestIdType = 0xBCDEu
30 | val expected = Net.createPacketBuffer(SetFormatData.SIZE)
31 | expected.putUShort(requestId)
32 | expected.putUByte(compression.toUByte())
33 | expected.rewind()
34 |
35 | val actual = Net.createPacketBuffer(SetFormatData.SIZE)
36 | SetFormatData(compression, requestId).write(actual)
37 | actual.rewind()
38 |
39 | Assertions.assertEquals(expected, actual)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/opus_license.txt:
--------------------------------------------------------------------------------
1 | Opus - https://opus-codec.org
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4 |
5 | - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6 |
7 | - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8 |
9 | - Neither the name of Internet Society, IETF or IETF Trust, nor the names of specific contributors, may be used to endorse or promote products derived from this software without specific prior written permission.
10 |
11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
12 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/network/ConnectDataTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.COMPRESSION_320
5 | import io.github.soundremote.util.Net.putUByte
6 | import io.github.soundremote.util.Net.putUShort
7 | import io.github.soundremote.util.PacketRequestIdType
8 | import org.junit.jupiter.api.Assertions
9 | import org.junit.jupiter.api.DisplayName
10 | import org.junit.jupiter.api.Test
11 |
12 | @DisplayName("ConnectData")
13 | internal class ConnectDataTest {
14 | @DisplayName("SIZE has correct value")
15 | @Test
16 | fun size_ReturnsCorrectValue() {
17 | val expected = 4
18 |
19 | val actual = ConnectData.SIZE
20 |
21 | Assertions.assertEquals(expected, actual)
22 | }
23 |
24 | @DisplayName("Writes correctly")
25 | @Test
26 | fun write_WritesCorrectly() {
27 | val requestId: PacketRequestIdType = 0xFAAFu
28 | @Net.Compression val compression = COMPRESSION_320
29 | val expected = Net.createPacketBuffer(ConnectData.SIZE)
30 | expected.putUByte(Net.PROTOCOL_VERSION)
31 | expected.putUShort(requestId)
32 | expected.putUByte(compression.toUByte())
33 | expected.rewind()
34 |
35 | val actual = Net.createPacketBuffer(ConnectData.SIZE)
36 | ConnectData(compression, requestId).write(actual)
37 | actual.rewind()
38 |
39 | Assertions.assertEquals(expected, actual)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/SystemEventActionRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import io.github.soundremote.data.room.EventActionDao
4 | import kotlinx.coroutines.CoroutineDispatcher
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.withContext
8 | import javax.inject.Inject
9 | import javax.inject.Singleton
10 |
11 | @Singleton
12 | class SystemEventActionRepository(
13 | private val eventActionDao: EventActionDao,
14 | private val dispatcher: CoroutineDispatcher,
15 | ) : EventActionRepository {
16 | @Inject
17 | constructor(eventActionDao: EventActionDao) : this(eventActionDao, Dispatchers.IO)
18 |
19 | override suspend fun getById(id: Int): EventAction? = withContext(dispatcher) {
20 | eventActionDao.getById(id)
21 | }
22 |
23 | override suspend fun insert(eventAction: EventAction): Unit = withContext(dispatcher) {
24 | eventActionDao.insert(eventAction)
25 | }
26 |
27 | override suspend fun update(eventAction: EventAction) = withContext(dispatcher) {
28 | eventActionDao.update(eventAction)
29 | }
30 |
31 | override suspend fun deleteById(id: Int) = withContext(dispatcher) {
32 | eventActionDao.deleteById(id)
33 | }
34 |
35 | override fun getAll(): Flow> =
36 | eventActionDao.getAll()
37 |
38 | override fun getShakeEventFlow(): Flow =
39 | eventActionDao.getEventActionFlow(Event.SHAKE.id)
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/util/KeysTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.util
2 |
3 | import android.content.Context
4 | import androidx.test.core.app.ApplicationProvider
5 | import org.hamcrest.CoreMatchers.containsString
6 | import org.hamcrest.CoreMatchers.not
7 | import org.hamcrest.MatcherAssert.assertThat
8 | import org.junit.Test
9 | import org.junit.experimental.runners.Enclosed
10 | import org.junit.runner.RunWith
11 |
12 | @RunWith(Enclosed::class)
13 | class KeysTest {
14 | class HotkeyDescription {
15 | private val context = ApplicationProvider.getApplicationContext()
16 | private val key = Key.BACK
17 | private val desc = generateDescription(
18 | key.keyCode,
19 | Mods(win = true, ctrl = false, shift = false, alt = true),
20 | )
21 |
22 | @Test
23 | fun asString_correctKeyLabel() {
24 | val expected = context.getString(key.labelId)
25 |
26 | val actual = desc.asString(context)
27 |
28 | assertThat(actual, containsString(expected))
29 | }
30 |
31 | @Test
32 | fun asString_correctMods() {
33 | val actual = desc.asString(context)
34 |
35 | assertThat(actual, containsString(ModKey.WIN.label))
36 | assertThat(actual, containsString(ModKey.ALT.label))
37 | assertThat(actual, not(containsString(ModKey.CTRL.label)))
38 | assertThat(actual, not(containsString(ModKey.SHIFT.label)))
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/AppViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import android.content.Context
4 | import androidx.annotation.StringRes
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import io.github.soundremote.service.ServiceManager
8 | import dagger.hilt.android.lifecycle.HiltViewModel
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.isActive
12 | import kotlinx.coroutines.launch
13 | import javax.inject.Inject
14 |
15 | @HiltViewModel
16 | internal class AppViewModel @Inject constructor(private val serviceManager: ServiceManager) :
17 | ViewModel() {
18 |
19 | private val _systemMessage = MutableStateFlow(null)
20 | val systemMessage: StateFlow
21 | get() = _systemMessage
22 |
23 | init {
24 | viewModelScope.launch {
25 | while (isActive) {
26 | val message = serviceManager.systemMessages.receive()
27 | setMessage(message.stringId)
28 | }
29 | }
30 | }
31 |
32 | fun bindConnection(context: Context) {
33 | serviceManager.bind(context)
34 | }
35 |
36 | fun unbindConnection(context: Context) {
37 | serviceManager.unbind(context)
38 | }
39 |
40 | private fun setMessage(@StringRes messageId: Int) {
41 | _systemMessage.value = messageId
42 | }
43 |
44 | fun messageShown() {
45 | _systemMessage.value = null
46 | }
47 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/hotkeylist/HotkeyListNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkeylist
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.hilt.navigation.compose.hiltViewModel
5 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
6 | import androidx.navigation.NavController
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.compose.composable
9 | import kotlinx.serialization.Serializable
10 |
11 | @Serializable
12 | object HotkeyListRoute
13 |
14 | fun NavController.navigateToHotkeyList() {
15 | navigate(HotkeyListRoute)
16 | }
17 |
18 | fun NavGraphBuilder.hotkeyListScreen(
19 | onNavigateToHotkeyCreate: () -> Unit,
20 | onNavigateToHotkeyEdit: (hotkeyId: Int) -> Unit,
21 | onNavigateUp: () -> Unit,
22 | ) {
23 | composable {
24 | val viewModel: HotkeyListViewModel = hiltViewModel()
25 | val state by viewModel.hotkeyListState.collectAsStateWithLifecycle()
26 | HotkeyListScreen(
27 | state = state,
28 | onNavigateToHotkeyCreate = onNavigateToHotkeyCreate,
29 | onNavigateToHotkeyEdit = onNavigateToHotkeyEdit,
30 | onDelete = { viewModel.deleteHotkey(it) },
31 | onChangeFavoured = { hotkeyId, favoured ->
32 | viewModel.changeFavoured(hotkeyId, favoured)
33 | },
34 | onMove = { fromIndex: Int, toIndex: Int ->
35 | viewModel.moveHotkey(fromIndex, toIndex)
36 | },
37 | onNavigateUp = onNavigateUp,
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/data/MigrationTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import androidx.room.Room
4 | import androidx.room.testing.MigrationTestHelper
5 | import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
6 | import androidx.test.platform.app.InstrumentationRegistry
7 | import io.github.soundremote.data.room.AppDatabase
8 | import io.github.soundremote.data.room.DatabaseMigrations
9 | import org.junit.Rule
10 | import org.junit.Test
11 | import java.io.IOException
12 |
13 | class MigrationTest {
14 | private val testDB = "migration-test"
15 |
16 | private val migrations = listOf(
17 | DatabaseMigrations.Schema1to2(),
18 | DatabaseMigrations.Schema2to3(),
19 | )
20 |
21 | @get:Rule
22 | val helper: MigrationTestHelper = MigrationTestHelper(
23 | InstrumentationRegistry.getInstrumentation(),
24 | AppDatabase::class.java,
25 | migrations,
26 | FrameworkSQLiteOpenHelperFactory()
27 | )
28 |
29 | @Test
30 | @Throws(IOException::class)
31 | fun migrateAll() {
32 | // Create earliest version of the database.
33 | helper.createDatabase(testDB, 1).apply {
34 | close()
35 | }
36 |
37 | // Open latest version of the database. Room validates the schema
38 | // once all migrations execute.
39 | Room.databaseBuilder(
40 | InstrumentationRegistry.getInstrumentation().targetContext,
41 | AppDatabase::class.java,
42 | testDB
43 | ).build().apply { openHelper.writableDatabase.close() }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/util/System.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.util
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import android.provider.Settings
7 | import androidx.annotation.StringRes
8 | import io.github.soundremote.R
9 |
10 | internal const val ACTION_CLOSE = "io.github.soundremote.ACTION_CLOSE"
11 |
12 | internal enum class SystemMessage(@StringRes val stringId: Int) {
13 | MESSAGE_CONNECT_FAILED(R.string.message_connect_failed),
14 | MESSAGE_ALREADY_BOUND(R.string.message_already_bound),
15 | MESSAGE_BIND_ERROR(R.string.message_bind_error),
16 | MESSAGE_DISCONNECTED(R.string.message_disconnected),
17 | MESSAGE_AUDIO_FOCUS_REQUEST_FAILED(R.string.message_audio_focus_request_failed),
18 | }
19 |
20 | // https://stackoverflow.com/questions/32822101/how-can-i-programmatically-open-the-permission-screen-for-a-specific-app-on-andr
21 | /**
22 | * Shows system app info screen
23 | */
24 | internal fun showAppInfo(context: Context) {
25 | val uri = Uri.fromParts("package", context.packageName, null)
26 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri)
27 | .addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
28 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
29 | .addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
30 | context.startActivity(intent)
31 | }
32 |
33 | internal enum class AppPermission(val id: String, val nameStringId: Int) {
34 | Phone(android.Manifest.permission.READ_PHONE_STATE, R.string.permission_name_phone)
35 | }
36 |
37 | sealed interface TextValue {
38 | data class TextString(val str: String) : TextValue
39 | data class TextResource(@StringRes val strId: Int) : TextValue
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/room/AppDatabase.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.room
2 |
3 | import androidx.room.AutoMigration
4 | import androidx.room.Database
5 | import androidx.room.RoomDatabase
6 | import androidx.sqlite.db.SupportSQLiteDatabase
7 | import io.github.soundremote.data.ActionData
8 | import io.github.soundremote.data.ActionType
9 | import io.github.soundremote.data.EventAction
10 | import io.github.soundremote.data.Hotkey
11 |
12 | @Database(
13 | entities = [
14 | Hotkey::class,
15 | EventAction::class
16 | ],
17 | version = 3,
18 | autoMigrations = [
19 | AutoMigration(from = 1, to = 2, spec = DatabaseMigrations.Schema1to2::class),
20 | AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
21 | ],
22 | exportSchema = true,
23 | )
24 | abstract class AppDatabase : RoomDatabase() {
25 | abstract fun hotkeyDao(): HotkeyDao
26 | abstract fun eventActionDao(): EventActionDao
27 |
28 | class Callback : RoomDatabase.Callback() {
29 | override fun onCreate(db: SupportSQLiteDatabase) {
30 | super.onCreate(db)
31 | db.execSQL(CREATE_TRIGGER_DELETE_EVENT_ACTION_ON_HOTKEY_DELETE)
32 | }
33 | }
34 | }
35 |
36 | // When a hotkey is deleted also delete all the event actions with that hotkey
37 | val CREATE_TRIGGER_DELETE_EVENT_ACTION_ON_HOTKEY_DELETE = """
38 | CREATE TRIGGER IF NOT EXISTS delete_event_action_on_hotkey_delete
39 | AFTER DELETE ON ${Hotkey.TABLE_NAME}
40 | BEGIN
41 | DELETE FROM ${EventAction.TABLE_NAME}
42 | WHERE ${ActionData.COLUMN_TYPE} = ${ActionType.HOTKEY.id}
43 | AND ${ActionData.COLUMN_ID} = OLD.${Hotkey.COLUMN_ID};
44 | END
45 | """.trimIndent()
46 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/home/HomeNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.home
2 |
3 | import androidx.compose.material3.SnackbarDuration
4 | import androidx.compose.runtime.getValue
5 | import androidx.hilt.navigation.compose.hiltViewModel
6 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
7 | import androidx.navigation.NavGraphBuilder
8 | import androidx.navigation.compose.composable
9 | import kotlinx.serialization.Serializable
10 |
11 | @Serializable
12 | object HomeRoute
13 |
14 | fun NavGraphBuilder.homeScreen(
15 | onNavigateToHotkeyList: () -> Unit,
16 | onNavigateToEvents: () -> Unit,
17 | onNavigateToSettings: () -> Unit,
18 | onNavigateToAbout: () -> Unit,
19 | onNavigateToEditHotkey: (hotkeyId: Int) -> Unit,
20 | showSnackbar: (String, SnackbarDuration) -> Unit,
21 | ) {
22 | composable {
23 | val viewModel: HomeViewModel = hiltViewModel()
24 | val homeUIState by viewModel.homeUIState.collectAsStateWithLifecycle()
25 | HomeScreen(
26 | uiState = homeUIState,
27 | messageId = viewModel.messageState,
28 | onNavigateToEditHotkey = { onNavigateToEditHotkey(it) },
29 | onConnect = { viewModel.connect(it) },
30 | onDisconnect = viewModel::disconnect,
31 | onSendHotkey = { viewModel.sendHotkey(it) },
32 | onSendKey = { viewModel.sendKey(it) },
33 | onSetMuted = { viewModel.setMuted(it) },
34 | onMessageShown = viewModel::messageShown,
35 | onNavigateToHotkeyList = onNavigateToHotkeyList,
36 | onNavigateToEvents = onNavigateToEvents,
37 | onNavigateToSettings = onNavigateToSettings,
38 | onNavigateToAbout = onNavigateToAbout,
39 | showSnackbar = showSnackbar,
40 | )
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/UserHotkeyRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import io.github.soundremote.data.room.HotkeyDao
4 | import kotlinx.coroutines.CoroutineDispatcher
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.withContext
8 | import javax.inject.Inject
9 | import javax.inject.Singleton
10 |
11 | @Singleton
12 | class UserHotkeyRepository(
13 | private val hotkeyDao: HotkeyDao,
14 | private val dispatcher: CoroutineDispatcher,
15 | ) : HotkeyRepository {
16 | @Inject
17 | constructor(hotkeyDao: HotkeyDao) :
18 | this(hotkeyDao, Dispatchers.IO)
19 |
20 | override suspend fun getById(id: Int): Hotkey? = withContext(dispatcher) {
21 | hotkeyDao.getById(id)
22 | }
23 |
24 | override suspend fun insert(hotkey: Hotkey) = withContext(dispatcher) {
25 | hotkeyDao.insert(hotkey)
26 | }
27 |
28 | override suspend fun update(hotkey: Hotkey) = withContext(dispatcher) {
29 | hotkeyDao.update(hotkey)
30 | }
31 |
32 | override suspend fun deleteById(id: Int) = withContext(dispatcher) {
33 | hotkeyDao.deleteById(id)
34 | }
35 |
36 | override suspend fun changeFavoured(id: Int, favoured: Boolean) = withContext(dispatcher) {
37 | hotkeyDao.changeFavoured(id, favoured)
38 | }
39 |
40 | override fun getFavouredOrdered(favoured: Boolean): Flow> =
41 | hotkeyDao.getFavouredOrdered(favoured)
42 |
43 | override fun getAllOrdered(): Flow> =
44 | hotkeyDao.getAllOrdered()
45 |
46 | override fun getAllInfoOrdered(): Flow> =
47 | hotkeyDao.getAllInfoOrdered()
48 |
49 | override suspend fun updateOrders(hotkeyOrders: List) =
50 | withContext(dispatcher) {
51 | hotkeyDao.updateOrders(*hotkeyOrders.toTypedArray())
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/main/assets/opus_license.txt:
--------------------------------------------------------------------------------
1 | Copyright 2001-2011
2 | Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo
3 |
4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5 |
6 | - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7 |
8 | - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9 |
10 | - Neither the name of Internet Society, IETF or IETF Trust, nor the names of specific contributors, may be used to endorse or promote products derived from this software without specific prior written permission.
11 |
12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
13 |
14 | Opus is subject to the royalty-free patent licenses which are specified at:
15 |
16 | Xiph.Org Foundation:
17 | https://datatracker.ietf.org/ipr/1524/
18 |
19 | Microsoft Corporation:
20 | https://datatracker.ietf.org/ipr/1914/
21 |
22 | Broadcom Corporation:
23 | https://datatracker.ietf.org/ipr/1526/
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/service/TestServiceManager.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.service
2 |
3 | import android.content.Context
4 | import io.github.soundremote.data.Hotkey
5 | import io.github.soundremote.util.ConnectionStatus
6 | import io.github.soundremote.util.Key
7 | import io.github.soundremote.util.SystemMessage
8 | import kotlinx.coroutines.channels.ReceiveChannel
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.StateFlow
11 | import kotlinx.coroutines.flow.update
12 |
13 | internal class TestServiceManager : ServiceManager {
14 | private val _serviceState = MutableStateFlow(ServiceState())
15 | override val serviceState: StateFlow
16 | get() = _serviceState
17 | override val systemMessages: ReceiveChannel
18 | get() = TODO("Not yet implemented")
19 |
20 | override fun bind(context: Context) {
21 | TODO("Not yet implemented")
22 | }
23 |
24 | override fun unbind(context: Context) {
25 | TODO("Not yet implemented")
26 | }
27 |
28 | override fun connect(address: String) {
29 | _serviceState.update {
30 | it.copy(connectionStatus = ConnectionStatus.CONNECTED)
31 | }
32 | }
33 |
34 | override fun disconnect() {
35 | _serviceState.update {
36 | it.copy(connectionStatus = ConnectionStatus.DISCONNECTED)
37 | }
38 | }
39 |
40 | override fun sendHotkey(hotkey: Hotkey) {
41 | sentHotkey = hotkey
42 | }
43 |
44 | override fun sendKey(key: Key) {
45 | sentKey = key
46 | }
47 |
48 | override fun setMuted(value: Boolean) {
49 | _serviceState.update {
50 | it.copy(isMuted = value)
51 | }
52 | }
53 |
54 | // Test only
55 | fun setServiceState(state: ServiceState) {
56 | _serviceState.value = state
57 | }
58 |
59 | var sentHotkey: Hotkey? = null
60 | var sentKey: Key? = null
61 | }
62 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import android.media.AudioManager
8 | import android.os.Bundle
9 | import androidx.activity.ComponentActivity
10 | import androidx.activity.compose.setContent
11 | import androidx.activity.enableEdgeToEdge
12 | import androidx.core.content.ContextCompat
13 | import dagger.hilt.android.AndroidEntryPoint
14 | import io.github.soundremote.service.MainService
15 | import io.github.soundremote.ui.SoundRemoteApp
16 | import io.github.soundremote.ui.theme.SoundRemoteTheme
17 | import io.github.soundremote.util.ACTION_CLOSE
18 |
19 | @AndroidEntryPoint
20 | class MainActivity : ComponentActivity() {
21 | private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() {
22 | override fun onReceive(context: Context, intent: Intent) {
23 | when (intent.action) {
24 | ACTION_CLOSE -> finishAndRemoveTask()
25 | }
26 | }
27 | }
28 |
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | enableEdgeToEdge()
32 | setContent {
33 | SoundRemoteTheme {
34 | SoundRemoteApp()
35 | }
36 | }
37 | volumeControlStream = AudioManager.STREAM_MUSIC
38 | ContextCompat.registerReceiver(
39 | this,
40 | broadcastReceiver,
41 | IntentFilter(ACTION_CLOSE),
42 | ContextCompat.RECEIVER_NOT_EXPORTED
43 | )
44 | startService()
45 | }
46 |
47 | override fun onDestroy() {
48 | super.onDestroy()
49 | unregisterReceiver(broadcastReceiver)
50 | }
51 |
52 | private fun startService() {
53 | val intent = Intent(this, MainService::class.java)
54 | ContextCompat.startForegroundService(this, intent)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/settings/SettingsViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import io.github.soundremote.data.preferences.PreferencesRepository
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.flow.SharingStarted
8 | import kotlinx.coroutines.flow.StateFlow
9 | import kotlinx.coroutines.flow.map
10 | import kotlinx.coroutines.flow.stateIn
11 | import kotlinx.coroutines.launch
12 | import javax.inject.Inject
13 |
14 | @HiltViewModel
15 | class SettingsViewModel @Inject constructor(
16 | private val preferencesRepository: PreferencesRepository
17 | ) : ViewModel() {
18 | val settings: StateFlow =
19 | preferencesRepository.settingsScreenPreferencesFlow.map { prefs ->
20 | SettingsUIState(
21 | serverPort = prefs.serverPort,
22 | clientPort = prefs.clientPort,
23 | audioCompression = prefs.audioCompression,
24 | ignoreAudioFocus = prefs.ignoreAudioFocus,
25 | )
26 | }.stateIn(
27 | scope = viewModelScope,
28 | started = SharingStarted.WhileSubscribed(5_000),
29 | initialValue = SettingsUIState(),
30 | )
31 |
32 | fun setServerPort(value: Int) {
33 | viewModelScope.launch { preferencesRepository.setServerPort(value) }
34 | }
35 |
36 | fun setClientPort(value: Int) {
37 | viewModelScope.launch { preferencesRepository.setClientPort(value) }
38 | }
39 |
40 | fun setAudioCompression(value: Int) {
41 | viewModelScope.launch { preferencesRepository.setAudioCompression(value) }
42 | }
43 |
44 | fun setIgnoreAudioFocus(value: Boolean) {
45 | viewModelScope.launch { preferencesRepository.setIgnoreAudioFocus(value) }
46 | }
47 | }
48 |
49 | data class SettingsUIState(
50 | val serverPort: Int = 0,
51 | val clientPort: Int = 0,
52 | val audioCompression: Int = 0,
53 | val ignoreAudioFocus: Boolean = false,
54 | )
55 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/room/HotkeyDao.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.room
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Query
5 | import androidx.room.Update
6 | import io.github.soundremote.data.Hotkey
7 | import io.github.soundremote.data.HotkeyInfo
8 | import io.github.soundremote.data.HotkeyOrder
9 | import kotlinx.coroutines.flow.Flow
10 |
11 | @Dao
12 | interface HotkeyDao : BaseDao {
13 | @Query("SELECT * FROM ${Hotkey.TABLE_NAME} WHERE ${Hotkey.COLUMN_ID} = :id")
14 | suspend fun getById(id: Int): Hotkey?
15 |
16 | @Query("DELETE FROM ${Hotkey.TABLE_NAME} WHERE ${Hotkey.COLUMN_ID} = :id")
17 | suspend fun deleteById(id: Int)
18 |
19 | @Query(
20 | """
21 | UPDATE ${Hotkey.TABLE_NAME}
22 | SET ${Hotkey.COLUMN_FAVOURED} = :favoured
23 | WHERE ${Hotkey.COLUMN_ID} = :id;
24 | """
25 | )
26 | suspend fun changeFavoured(id: Int, favoured: Boolean)
27 |
28 | @Query(
29 | """
30 | SELECT
31 | ${Hotkey.COLUMN_ID},
32 | ${Hotkey.COLUMN_KEY_CODE},
33 | ${Hotkey.COLUMN_MODS},
34 | ${Hotkey.COLUMN_NAME}
35 | FROM ${Hotkey.TABLE_NAME}
36 | WHERE ${Hotkey.COLUMN_FAVOURED} = :favoured
37 | ORDER BY ${Hotkey.COLUMN_ORDER} DESC, ${Hotkey.COLUMN_ID};
38 | """
39 | )
40 | fun getFavouredOrdered(favoured: Boolean): Flow>
41 |
42 | @Query(
43 | """
44 | SELECT * FROM ${Hotkey.TABLE_NAME}
45 | ORDER BY ${Hotkey.COLUMN_ORDER} DESC, ${Hotkey.COLUMN_ID};
46 | """
47 | )
48 | fun getAllOrdered(): Flow>
49 |
50 | @Query(
51 | """
52 | SELECT ${Hotkey.COLUMN_ID},
53 | ${Hotkey.COLUMN_KEY_CODE},
54 | ${Hotkey.COLUMN_MODS},
55 | ${Hotkey.COLUMN_NAME}
56 | FROM ${Hotkey.TABLE_NAME}
57 | ORDER BY ${Hotkey.COLUMN_ORDER} DESC, ${Hotkey.COLUMN_ID};
58 | """
59 | )
60 | fun getAllInfoOrdered(): Flow>
61 |
62 | @Update(entity = Hotkey::class)
63 | suspend fun updateOrders(vararg hotkeyOrders: HotkeyOrder)
64 | }
65 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ru/keys.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Буква или цифра
5 | Пунктуация
6 | Мультимедиа
7 | Перемещение
8 | Цифровая клавиатура
9 | Управление
10 | Функции
11 |
12 | Обратный апостроф/Тильда
13 | Минус/Подчёркивание
14 | Равно/Плюс
15 | Левая квадратная/фигурная скобка
16 | Правая квадратная/фигурная скобка
17 | Точка с запятой/Двоеточие
18 | Апостроф/Кавычки
19 | Бэкслеш/Вертикальная черта
20 | Запятая/Знак меньше
21 | Точка/Знак больше
22 | Слеш/Вопросительный знак
23 | Стрелка вверх
24 | Стрелка вниз
25 | Стрелка вправо
26 | Стрелка влево
27 |
28 | Воспроизведение/Пауза
29 | Следующий трек
30 | Предыдущий трек
31 | Стоп
32 | Отключить звук
33 | Уменьшить громкость
34 | Увеличить громкость
35 |
36 | Пробел
37 |
38 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/data/HotkeyTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import io.github.soundremote.getHotkey
4 | import io.github.soundremote.util.ModKey
5 | import io.github.soundremote.util.Mods
6 | import io.github.soundremote.util.isModActive
7 | import org.junit.jupiter.api.Assertions.*
8 | import org.junit.jupiter.api.DisplayName
9 | import org.junit.jupiter.api.Nested
10 | import org.junit.jupiter.api.Test
11 | import org.junit.jupiter.params.ParameterizedTest
12 | import org.junit.jupiter.params.provider.EnumSource
13 |
14 | @DisplayName("Hotkey")
15 | internal class HotkeyTest {
16 |
17 | @DisplayName("Mods")
18 | @Nested
19 | inner class ModsTests {
20 | @DisplayName("Are not set when created without mods")
21 | @ParameterizedTest
22 | @EnumSource(ModKey::class)
23 | fun isModActive_NoMods_ReturnsFalse(mod: ModKey) {
24 | val hotkey = getHotkey(mods = Mods())
25 |
26 | val modActive = hotkey.isModActive(mod)
27 |
28 | assertFalse(modActive)
29 | }
30 |
31 | @DisplayName("Are set when created with mods")
32 | @ParameterizedTest
33 | @EnumSource(ModKey::class)
34 | fun isModActive_WithMods_ReturnsTrue(mod: ModKey) {
35 | val hotkey = getHotkey(mods = Mods(mod.bitField))
36 |
37 | val modActive = hotkey.isModActive(mod)
38 |
39 | assertTrue(modActive)
40 | }
41 |
42 | @DisplayName("Bitfield is correct set when created without mods")
43 | @Test
44 | fun modsBitfield_NoMods_ReturnsCorrectValue() {
45 | val expected = Mods()
46 | val hotkey = getHotkey(mods = Mods())
47 |
48 | val actual = hotkey.mods
49 |
50 | assertEquals(expected, actual)
51 | }
52 |
53 | @DisplayName("Bitfield is correct set when created with Mods")
54 | @ParameterizedTest
55 | @EnumSource(ModKey::class)
56 | fun modsBitfield_WithMod_ReturnsCorrectValue(mod: ModKey) {
57 | val expected = Mods(mod.bitField)
58 | val hotkey = getHotkey(mods = Mods(mod.bitField))
59 |
60 | val actual = hotkey.mods
61 |
62 | assertEquals(expected, actual)
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/data/TestEventActionRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import kotlinx.coroutines.channels.BufferOverflow
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.MutableSharedFlow
6 | import kotlinx.coroutines.flow.flowOf
7 |
8 | class TestEventActionRepository : EventActionRepository {
9 | private val eventActionsFlow: MutableSharedFlow> = MutableSharedFlow(
10 | replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
11 | )
12 | private val currentEventActions: List
13 | get() = eventActionsFlow.replayCache.firstOrNull() ?: emptyList()
14 |
15 | override suspend fun getById(id: Int): EventAction? {
16 | return currentEventActions.find { it.eventId == id }?.copy()
17 | }
18 |
19 | override suspend fun insert(eventAction: EventAction) {
20 | val existing = currentEventActions.find { it.eventId == eventAction.eventId }
21 | if (existing == null) {
22 | eventActionsFlow.tryEmit(currentEventActions + eventAction)
23 | } else {
24 | existing.action = eventAction.action.copy()
25 | eventActionsFlow.tryEmit(currentEventActions)
26 | }
27 | }
28 |
29 | override suspend fun update(eventAction: EventAction): Int {
30 | val toUpdate = currentEventActions.find { it.eventId == eventAction.eventId } ?: return 0
31 | toUpdate.action = eventAction.action.copy()
32 | eventActionsFlow.tryEmit(currentEventActions)
33 | return 1
34 | }
35 |
36 | override suspend fun deleteById(id: Int) {
37 | val alteredList = currentEventActions.toMutableList()
38 | // If nothing to remove, do not emit
39 | if (!alteredList.removeIf { it.eventId == id }) return
40 | eventActionsFlow.tryEmit(alteredList)
41 | }
42 |
43 | override fun getAll(): Flow> = eventActionsFlow
44 |
45 | override fun getShakeEventFlow(): Flow {
46 | val shakeAction = currentEventActions.find { it.eventId == Event.SHAKE.id }
47 | return flowOf(shakeAction)
48 | }
49 |
50 | // Test methods
51 | fun setEventActions(eventActions: List) {
52 | eventActionsFlow.tryEmit(eventActions)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/data/AppDatabaseTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import io.github.soundremote.util.KeyCode
4 | import kotlinx.coroutines.test.StandardTestDispatcher
5 | import kotlinx.coroutines.test.runTest
6 | import org.hamcrest.CoreMatchers.equalTo
7 | import org.hamcrest.CoreMatchers.nullValue
8 | import org.hamcrest.MatcherAssert.assertThat
9 | import org.junit.BeforeClass
10 | import org.junit.Ignore
11 | import org.junit.Rule
12 | import org.junit.Test
13 | import org.junit.experimental.runners.Enclosed
14 | import org.junit.runner.RunWith
15 |
16 | @RunWith(Enclosed::class)
17 | internal class AppDatabaseTest {
18 | @Ignore
19 | companion object {
20 |
21 | private val dispatcher = StandardTestDispatcher()
22 |
23 | @JvmStatic
24 | @BeforeClass
25 | fun setUpClass() {
26 | }
27 | }
28 |
29 | internal class TriggersAndForeignKeysTests {
30 | @JvmField
31 | @Rule
32 | val database = DatabaseResource(dispatcher)
33 |
34 | @Test
35 | fun createHotkey_setsOrderToDefaultValue() = runTest(dispatcher) {
36 | val expected = Hotkey.ORDER_DEFAULT_VALUE
37 | val hotkeyId = database.hotkeyRepository.insert(Hotkey(KeyCode(1), "Test"))
38 |
39 | val actual = database.hotkeyRepository.getById(hotkeyId.toInt())?.order
40 |
41 | assertThat(
42 | "Creating a Hotkey must init the order field with the default value",
43 | actual, equalTo(expected)
44 | )
45 | }
46 |
47 | @Test
48 | fun deleteEventBoundHotkey_deletesEventAction() = runTest(dispatcher) {
49 | val hotkey = Hotkey(KeyCode(123), "Test")
50 | val hotkeyId = database.hotkeyRepository.insert(hotkey).toInt()
51 | val eventId = Event.CALL_END.id
52 | val eventAction = EventAction(eventId, ActionData(ActionType.HOTKEY, hotkeyId))
53 | database.eventActionRepository.insert(eventAction)
54 |
55 | database.hotkeyRepository.deleteById(hotkeyId)
56 |
57 | val actual: EventAction? = database.eventActionRepository.getById(eventId)
58 | assertThat(
59 | "Deleting Event bound Hotkey must delete the EventAction",
60 | actual, nullValue()
61 | )
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/network/PacketHeader.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.network
2 |
3 | import io.github.soundremote.util.Net
4 | import io.github.soundremote.util.Net.putUByte
5 | import io.github.soundremote.util.Net.putUShort
6 | import io.github.soundremote.util.Net.uByte
7 | import io.github.soundremote.util.Net.uShort
8 | import io.github.soundremote.util.PacketCategoryType
9 | import java.nio.ByteBuffer
10 |
11 | data class PacketHeader(val category: PacketCategoryType, val packetSize: Int) {
12 | constructor(category: Net.PacketCategory, packetSize: Int) : this(category.value, packetSize)
13 |
14 | /**
15 | * Writes this header to the given ByteBuffer and increments its position by [SIZE].
16 | * @param dest [ByteBuffer] to write to.
17 | * @throws IllegalArgumentException if there are fewer than [SIZE] bytes remaining in [dest].
18 | */
19 | fun write(dest: ByteBuffer) {
20 | require(dest.remaining() >= SIZE)
21 | dest.putUShort(Net.PROTOCOL_SIGNATURE)
22 | dest.putUByte(category)
23 | dest.putUShort(packetSize.toUShort())
24 | }
25 |
26 | companion object {
27 | /*
28 | unsigned 16bit protocol signature
29 | unsigned 8bit packet category
30 | unsigned 16bit packet size including header
31 | */
32 | /**
33 | * Network packet header size in bytes
34 | */
35 | const val SIZE = 5
36 |
37 | /**
38 | * Read a [PacketHeader] from the source ByteBuffer which must contain exactly one datagram.
39 | * This method checks protocol signature and packet size. Increments [buffer] position by
40 | * [SIZE] on successful read, or by an arbitrary value on fail.
41 | * @param buffer [ByteBuffer] to read from
42 | * @return The header or null if the [buffer] doesn't contain a single datagram with a valid
43 | * header
44 | */
45 | fun read(buffer: ByteBuffer): PacketHeader? {
46 | if (buffer.remaining() < SIZE) return null
47 | val signature = buffer.uShort
48 | if (signature != Net.PROTOCOL_SIGNATURE) return null
49 | val category = buffer.uByte
50 | val packetSize = buffer.uShort.toInt()
51 | val header = PacketHeader(category, packetSize)
52 | return if (buffer.limit() != header.packetSize) null else header
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/home/MediaBar.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.home
2 |
3 | import androidx.annotation.DrawableRes
4 | import androidx.annotation.StringRes
5 | import androidx.compose.foundation.background
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.material3.Icon
10 | import androidx.compose.material3.IconButton
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.res.painterResource
15 | import androidx.compose.ui.res.stringResource
16 | import androidx.compose.ui.tooling.preview.Preview
17 | import io.github.soundremote.R
18 | import io.github.soundremote.util.Key
19 |
20 | @Composable
21 | fun MediaBar(
22 | onKeyPress: (Key) -> Unit,
23 | ) {
24 | Row(
25 | modifier = Modifier
26 | .background(color = MaterialTheme.colorScheme.surfaceContainer)
27 | .fillMaxWidth(),
28 | horizontalArrangement = Arrangement.Center,
29 | ) {
30 | MediaButton(R.drawable.ic_play_pause, R.string.key_media_play_pause) {
31 | onKeyPress(Key.MEDIA_PLAY_PAUSE)
32 | }
33 | MediaButton(R.drawable.ic_stop, R.string.key_media_stop) {
34 | onKeyPress(Key.MEDIA_STOP)
35 | }
36 | MediaButton(R.drawable.ic_skip_previous, R.string.key_media_prev) {
37 | onKeyPress(Key.MEDIA_PREV)
38 | }
39 | MediaButton(R.drawable.ic_skip_next, R.string.key_media_next) {
40 | onKeyPress(Key.MEDIA_NEXT)
41 | }
42 | MediaButton(R.drawable.ic_volume_mute, R.string.key_media_volume_mute) {
43 | onKeyPress(Key.MEDIA_VOLUME_MUTE)
44 | }
45 | MediaButton(R.drawable.ic_volume_down, R.string.key_media_volume_down) {
46 | onKeyPress(Key.MEDIA_VOLUME_DOWN)
47 | }
48 | MediaButton(R.drawable.ic_volume_up, R.string.key_media_volume_up) {
49 | onKeyPress(Key.MEDIA_VOLUME_UP)
50 | }
51 | }
52 | }
53 |
54 | @Composable
55 | private fun MediaButton(
56 | @DrawableRes icon: Int,
57 | @StringRes description: Int,
58 | onClick: () -> Unit,
59 | ) {
60 | IconButton(onClick = onClick) {
61 | Icon(
62 | painter = painterResource(icon),
63 | contentDescription = stringResource(description),
64 | )
65 | }
66 | }
67 |
68 | @Preview
69 | @Composable
70 | private fun MediaBarPreview() {
71 | MediaBar {}
72 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
28 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/hotkeylist/HotkeyListViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkeylist
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import io.github.soundremote.data.HotkeyOrder
6 | import io.github.soundremote.data.HotkeyRepository
7 | import io.github.soundremote.util.HotkeyDescription
8 | import io.github.soundremote.util.generateDescription
9 | import dagger.hilt.android.lifecycle.HiltViewModel
10 | import kotlinx.coroutines.flow.SharingStarted
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.map
13 | import kotlinx.coroutines.flow.stateIn
14 | import kotlinx.coroutines.launch
15 | import javax.inject.Inject
16 |
17 | data class HotkeyUIState(
18 | val id: Int,
19 | val name: String,
20 | val description: HotkeyDescription,
21 | val favoured: Boolean
22 | )
23 |
24 | data class HotkeyListUIState(
25 | val hotkeys: List = emptyList()
26 | )
27 |
28 | @HiltViewModel
29 | class HotkeyListViewModel @Inject constructor(
30 | private val hotkeyRepository: HotkeyRepository,
31 | ) : ViewModel() {
32 | val hotkeyListState: StateFlow = hotkeyRepository.getAllOrdered()
33 | .map { hotkeys ->
34 | val hotkeyUIStates = hotkeys.map { hotkey ->
35 | HotkeyUIState(
36 | hotkey.id,
37 | hotkey.name,
38 | description = generateDescription(hotkey),
39 | favoured = hotkey.isFavoured
40 | )
41 | }
42 | HotkeyListUIState(hotkeyUIStates)
43 | }.stateIn(
44 | scope = viewModelScope,
45 | started = SharingStarted.WhileSubscribed(5_000),
46 | initialValue = HotkeyListUIState()
47 | )
48 |
49 | fun moveHotkey(fromIndex: Int, toIndex: Int) {
50 | viewModelScope.launch {
51 | val orderedIds = hotkeyListState.value.hotkeys.map { it.id }.toMutableList()
52 | require(fromIndex in orderedIds.indices && toIndex in orderedIds.indices) { "Invalid indices" }
53 | orderedIds.add(toIndex, orderedIds.removeAt(fromIndex))
54 | val orders =
55 | orderedIds.mapIndexed { index, id -> HotkeyOrder(id, orderedIds.size - index) }
56 | hotkeyRepository.updateOrders(orders)
57 | }
58 | }
59 |
60 | fun deleteHotkey(id: Int) {
61 | viewModelScope.launch {
62 | hotkeyRepository.deleteById(id)
63 | }
64 | }
65 |
66 | fun changeFavoured(hotkeyId: Int, favoured: Boolean) {
67 | viewModelScope.launch {
68 | hotkeyRepository.changeFavoured(hotkeyId, favoured)
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/settings/BooleanPreference.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.heightIn
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.selection.toggleable
10 | import androidx.compose.material3.Switch
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.semantics.Role
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import io.github.soundremote.ui.components.ListItemHeadline
22 | import io.github.soundremote.ui.components.ListItemSupport
23 |
24 | @Composable
25 | internal fun BooleanPreference(
26 | title: String,
27 | summary: String,
28 | value: Boolean,
29 | onPreferenceChange: (Boolean) -> Unit,
30 | modifier: Modifier = Modifier,
31 | ) {
32 | Row(
33 | verticalAlignment = Alignment.CenterVertically,
34 | horizontalArrangement = Arrangement.spacedBy(16.dp),
35 | modifier = modifier
36 | .fillMaxWidth()
37 | .toggleable(
38 | value = value,
39 | role = Role.Switch,
40 | onValueChange = onPreferenceChange,
41 | )
42 | .padding(horizontal = 16.dp, vertical = 8.dp)
43 | // 56 + 8 * 2(vertical padding) = 72 (recommended height for a two lines list item container)
44 | .heightIn(min = 56.dp)
45 | ) {
46 | Column(
47 | modifier = Modifier.weight(1f)
48 | ) {
49 | ListItemHeadline(title)
50 | ListItemSupport(summary)
51 | }
52 | Switch(
53 | checked = value,
54 | onCheckedChange = onPreferenceChange,
55 | )
56 | }
57 | }
58 |
59 | @Preview(showBackground = true)
60 | @Composable
61 | private fun BooleanPreferencePreview1() {
62 | var state by remember { mutableStateOf(false) }
63 | BooleanPreference(
64 | title = "Pref title",
65 | summary = "a ".repeat(100),
66 | value = state,
67 | onPreferenceChange = { state = it },
68 | )
69 | }
70 |
71 | @Preview(showBackground = true)
72 | @Composable
73 | private fun BooleanPreferencePreview2() {
74 | var state by remember { mutableStateOf(false) }
75 | BooleanPreference(
76 | title = "Pref title",
77 | summary = "1 line summary",
78 | value = state,
79 | onPreferenceChange = { state = it },
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/ui/SettingsViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import io.github.soundremote.MainDispatcherExtension
4 | import io.github.soundremote.data.preferences.TestPreferencesRepository
5 | import io.github.soundremote.ui.settings.SettingsViewModel
6 | import io.github.soundremote.util.DEFAULT_AUDIO_COMPRESSION
7 | import io.github.soundremote.util.DEFAULT_CLIENT_PORT
8 | import io.github.soundremote.util.DEFAULT_SERVER_PORT
9 | import io.github.soundremote.util.Net.COMPRESSION_320
10 | import kotlinx.coroutines.ExperimentalCoroutinesApi
11 | import kotlinx.coroutines.launch
12 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
13 | import kotlinx.coroutines.test.runTest
14 | import org.junit.jupiter.api.Assertions.assertEquals
15 | import org.junit.jupiter.api.BeforeEach
16 | import org.junit.jupiter.api.DisplayName
17 | import org.junit.jupiter.api.Test
18 | import org.junit.jupiter.api.extension.ExtendWith
19 |
20 | @OptIn(ExperimentalCoroutinesApi::class)
21 | @ExtendWith(MainDispatcherExtension::class)
22 | @DisplayName("SettingsViewModel")
23 | class SettingsViewModelTest {
24 | private val preferencesRepository = TestPreferencesRepository()
25 |
26 | private lateinit var viewModel: SettingsViewModel
27 |
28 | @BeforeEach
29 | fun setup() {
30 | viewModel = SettingsViewModel(preferencesRepository)
31 | }
32 |
33 | @Test
34 | @DisplayName("Setting audio compression updates settings")
35 | fun audioCompression_changes_settingsStateUpdates() = runTest {
36 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
37 | viewModel.settings.collect {}
38 | }
39 |
40 | assertEquals(DEFAULT_AUDIO_COMPRESSION, viewModel.settings.value.audioCompression)
41 | val expected = COMPRESSION_320
42 |
43 | preferencesRepository.setAudioCompression(expected)
44 |
45 | assertEquals(expected, viewModel.settings.value.audioCompression)
46 |
47 | collectJob.cancel()
48 | }
49 |
50 | @Test
51 | @DisplayName("Setting client port updates settings")
52 | fun clientPort_changes_settingsStateUpdates() = runTest {
53 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
54 | viewModel.settings.collect {}
55 | }
56 |
57 | assertEquals(DEFAULT_CLIENT_PORT, viewModel.settings.value.clientPort)
58 | val expected = 33333
59 |
60 | preferencesRepository.setClientPort(expected)
61 |
62 | assertEquals(expected, viewModel.settings.value.clientPort)
63 |
64 | collectJob.cancel()
65 | }
66 |
67 | @Test
68 | @DisplayName("Setting server port updates settings")
69 | fun serverPort_changes_settingsStateUpdates() = runTest {
70 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
71 | viewModel.settings.collect {}
72 | }
73 |
74 | assertEquals(DEFAULT_SERVER_PORT, viewModel.settings.value.serverPort)
75 | val expected = 44444
76 |
77 | preferencesRepository.setServerPort(expected)
78 |
79 | assertEquals(expected, viewModel.settings.value.serverPort)
80 |
81 | collectJob.cancel()
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/SoundRemoteApp.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import androidx.compose.foundation.layout.WindowInsets
4 | import androidx.compose.foundation.layout.WindowInsetsSides
5 | import androidx.compose.foundation.layout.consumeWindowInsets
6 | import androidx.compose.foundation.layout.only
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.safeDrawing
9 | import androidx.compose.foundation.layout.safeDrawingPadding
10 | import androidx.compose.material3.Scaffold
11 | import androidx.compose.material3.SnackbarHost
12 | import androidx.compose.material3.SnackbarHostState
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.DisposableEffect
15 | import androidx.compose.runtime.LaunchedEffect
16 | import androidx.compose.runtime.getValue
17 | import androidx.compose.runtime.remember
18 | import androidx.compose.runtime.rememberCoroutineScope
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.platform.LocalContext
21 | import androidx.compose.ui.res.stringResource
22 | import androidx.hilt.navigation.compose.hiltViewModel
23 | import androidx.lifecycle.Lifecycle
24 | import androidx.lifecycle.LifecycleEventObserver
25 | import androidx.lifecycle.compose.LocalLifecycleOwner
26 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
27 | import kotlinx.coroutines.launch
28 |
29 | @Composable
30 | internal fun SoundRemoteApp(
31 | viewModel: AppViewModel = hiltViewModel(),
32 | ) {
33 | val scope = rememberCoroutineScope()
34 | val snackbarHostState = remember { SnackbarHostState() }
35 | val systemMessageId by viewModel.systemMessage.collectAsStateWithLifecycle()
36 |
37 | // Binding and unbinding the connection
38 | val lifecycleOwner = LocalLifecycleOwner.current
39 | val context = LocalContext.current
40 | DisposableEffect(lifecycleOwner) {
41 | val observer = LifecycleEventObserver { _, event ->
42 | if (event == Lifecycle.Event.ON_START) {
43 | viewModel.bindConnection(context)
44 | } else if (event == Lifecycle.Event.ON_STOP) {
45 | viewModel.unbindConnection(context)
46 | }
47 | }
48 | lifecycleOwner.lifecycle.addObserver(observer)
49 | onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
50 | }
51 |
52 | systemMessageId?.let { id ->
53 | val message = stringResource(id)
54 | LaunchedEffect(id) {
55 | snackbarHostState.showSnackbar(message)
56 | viewModel.messageShown()
57 | }
58 | }
59 |
60 | Scaffold(
61 | snackbarHost = { SnackbarHost(snackbarHostState, Modifier.safeDrawingPadding()) },
62 | contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal),
63 | ) { paddingValues ->
64 | AppNavigation(
65 | showSnackbar = { message, duration ->
66 | scope.launch {
67 | snackbarHostState.showSnackbar(message = message, duration = duration)
68 | }
69 | },
70 | modifier = Modifier
71 | .padding(paddingValues)
72 | .consumeWindowInsets(paddingValues)
73 | )
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/audio/decoder/OpusAudioDecoder.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.audio.decoder
2 |
3 | import ashipo.jopus.OPUS_OK
4 | import ashipo.jopus.Opus
5 | import io.github.soundremote.util.Audio.CHANNELS
6 | import io.github.soundremote.util.Audio.PACKET_DURATION
7 | import io.github.soundremote.util.Audio.SAMPLE_RATE
8 | import io.github.soundremote.util.Audio.SAMPLE_SIZE
9 |
10 | /**
11 | * Creates new OpusAudioDecoder
12 | * @param sampleRate sample rate in Hz, must be 8/12/16/24/48 KHz
13 | * @param channels number of channels, must be 1 or 2
14 | * @param packetDuration packet duration in microseconds, must be a multiple of 2.5ms, maximum 60ms
15 | */
16 | class OpusAudioDecoder(
17 | private val sampleRate: Int = SAMPLE_RATE,
18 | private val channels: Int = CHANNELS,
19 | private val packetDuration: Int = PACKET_DURATION,
20 | ) {
21 | private val opus = Opus()
22 |
23 | /** Number of samples per channel (frames) in one packet */
24 | val framesPerPacket = (sampleRate.toLong() * packetDuration / 1_000_000).toInt()
25 |
26 | /** Number of bytes per PCM audio packet */
27 | val bytesPerPacket = framesToBytes(framesPerPacket)
28 |
29 | // 60ms is the maximum packet duration
30 | val maxPacketsPerPlc = 60_000 / packetDuration
31 |
32 | init {
33 | check(packetDuration in 2_500..60_000) {
34 | "Opus decoder packet duration must be from from 2.5 ms to 60 ms"
35 | }
36 | val initResult = opus.initDecoder(sampleRate, channels)
37 | if (initResult != OPUS_OK) {
38 | val errorString = opus.getErrorString(initResult)
39 | throw DecoderException("Opus decoder init error: $errorString")
40 | }
41 | }
42 |
43 | fun release() {
44 | opus.releaseDecoder()
45 | }
46 |
47 | fun decode(encodedData: ByteArray, decodedData: ByteArray): Int {
48 | val encodedBytes = encodedData.size
49 | val framesDecodedOrError =
50 | opus.decode(encodedData, encodedBytes, decodedData, framesPerPacket, 0)
51 | if (framesDecodedOrError < 0) {
52 | val errorString = opus.getErrorString(framesDecodedOrError)
53 | throw DecoderException("Opus decode error: $errorString")
54 | }
55 | return framesToBytes(framesDecodedOrError)
56 | }
57 |
58 | /**
59 | * Generates audio to fill for missing packets with Opus packet loss concealment (PLC)
60 | *
61 | * @param decodedData generated PCM audio
62 | * @param decodedFrames number of frames of available space in [decodedData]. Needs to be
63 | * exactly the duration of audio that is missing. Duration must be a multiple of 2.5 ms.
64 | *
65 | * @return number of frames generated
66 | */
67 | fun plc(decodedData: ByteArray, decodedFrames: Int): Int {
68 | val framesDecodedOrError = opus.plc(decodedData, decodedFrames)
69 | if (framesDecodedOrError < 0) {
70 | val errorString = opus.getErrorString(framesDecodedOrError)
71 | throw DecoderException("Opus PLC error: $errorString")
72 | }
73 | return framesToBytes(framesDecodedOrError)
74 | }
75 |
76 | private fun framesToBytes(frames: Int): Int {
77 | return frames * channels * SAMPLE_SIZE
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/data/TestHotkeyRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import kotlinx.coroutines.channels.BufferOverflow
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.MutableSharedFlow
6 | import kotlinx.coroutines.flow.map
7 | import java.util.concurrent.atomic.AtomicLong
8 |
9 | class TestHotkeyRepository : HotkeyRepository {
10 | private val _hotkeysFlow: MutableSharedFlow> =
11 | MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
12 | private val currentHotkeys get() = _hotkeysFlow.replayCache.firstOrNull() ?: emptyList()
13 | private val id = AtomicLong(1)
14 |
15 | init {
16 | // Set the initial value
17 | _hotkeysFlow.tryEmit(emptyList())
18 | }
19 |
20 | override suspend fun getById(id: Int): Hotkey? {
21 | return currentHotkeys.find { it.id == id }?.copy()
22 | }
23 |
24 | override suspend fun insert(hotkey: Hotkey): Long {
25 | val newId = id.getAndIncrement()
26 | val newHotkey = hotkey.copy(id = newId.toInt())
27 | _hotkeysFlow.emit(currentHotkeys + newHotkey)
28 | return newId
29 | }
30 |
31 | override suspend fun update(hotkey: Hotkey): Int {
32 | val indexToUpdate = currentHotkeys.indexOfFirst { it.id == hotkey.id }
33 | if (indexToUpdate == -1) return 0
34 | val updatedList = currentHotkeys.toMutableList()
35 | updatedList[indexToUpdate] = hotkey
36 | _hotkeysFlow.tryEmit(updatedList)
37 | return 1
38 | }
39 |
40 | override suspend fun deleteById(id: Int) {
41 | val hotkeys = currentHotkeys.toMutableList()
42 | hotkeys.removeIf { it.id == id }
43 | _hotkeysFlow.tryEmit(hotkeys)
44 | }
45 |
46 | override suspend fun changeFavoured(id: Int, favoured: Boolean) {
47 | val hotkeys = currentHotkeys
48 | hotkeys.find { it.id == id }!!.isFavoured = favoured
49 | _hotkeysFlow.tryEmit(hotkeys)
50 | }
51 |
52 | override fun getFavouredOrdered(favoured: Boolean): Flow> {
53 | return _hotkeysFlow.map { hotkeys ->
54 | hotkeys
55 | .filter { it.isFavoured == favoured }
56 | .sortedByDescending { it.order }
57 | .map { HotkeyInfo(it.id, it.keyCode, it.mods, it.name) }
58 | }
59 | }
60 |
61 | override fun getAllOrdered(): Flow> {
62 | return _hotkeysFlow.map { hotkeys ->
63 | hotkeys.sortedByDescending { it.order }
64 | }
65 | }
66 |
67 | override fun getAllInfoOrdered(): Flow> {
68 | return _hotkeysFlow.map { hotkeys ->
69 | hotkeys
70 | .sortedByDescending { it.order }
71 | .map { HotkeyInfo(it.id, it.keyCode, it.mods, it.name) }
72 | }
73 | }
74 |
75 | override suspend fun updateOrders(hotkeyOrders: List) {
76 | val hotkeys = currentHotkeys
77 | for ((id, order) in hotkeyOrders) {
78 | hotkeys.find { it.id == id }!!.order = order
79 | }
80 | _hotkeysFlow.tryEmit(hotkeys)
81 | }
82 |
83 | // Test methods
84 | fun setHotkeys(hotkeys: List) {
85 | _hotkeysFlow.tryEmit(hotkeys)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/schemas/io.github.soundremote.data.room.AppDatabase/2.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 2,
5 | "identityHash": "d0f4c3cb933ada5568e4175657b2724a",
6 | "entities": [
7 | {
8 | "tableName": "keystroke",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key_code` INTEGER NOT NULL, `mods` INTEGER NOT NULL, `name` TEXT NOT NULL, `favoured` INTEGER NOT NULL, `display_order` INTEGER NOT NULL DEFAULT 0)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "keyCode",
19 | "columnName": "key_code",
20 | "affinity": "INTEGER",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "mods",
25 | "columnName": "mods",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "name",
31 | "columnName": "name",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "isFavoured",
37 | "columnName": "favoured",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "order",
43 | "columnName": "display_order",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "autoGenerate": true,
51 | "columnNames": [
52 | "_id"
53 | ]
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | },
58 | {
59 | "tableName": "event_action",
60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `action_type` INTEGER NOT NULL DEFAULT 0, `action_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
61 | "fields": [
62 | {
63 | "fieldPath": "eventId",
64 | "columnName": "_id",
65 | "affinity": "INTEGER",
66 | "notNull": true
67 | },
68 | {
69 | "fieldPath": "action.actionType",
70 | "columnName": "action_type",
71 | "affinity": "INTEGER",
72 | "notNull": true,
73 | "defaultValue": "0"
74 | },
75 | {
76 | "fieldPath": "action.actionId",
77 | "columnName": "action_id",
78 | "affinity": "INTEGER",
79 | "notNull": true
80 | }
81 | ],
82 | "primaryKey": {
83 | "autoGenerate": false,
84 | "columnNames": [
85 | "_id"
86 | ]
87 | },
88 | "indices": [],
89 | "foreignKeys": []
90 | }
91 | ],
92 | "views": [],
93 | "setupQueries": [
94 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
95 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0f4c3cb933ada5568e4175657b2724a')"
96 | ]
97 | }
98 | }
--------------------------------------------------------------------------------
/app/schemas/io.github.soundremote.data.room.AppDatabase/3.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 3,
5 | "identityHash": "7244cde83b348567562269f460253824",
6 | "entities": [
7 | {
8 | "tableName": "hotkey",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key_code` INTEGER NOT NULL, `mods` INTEGER NOT NULL, `name` TEXT NOT NULL, `favoured` INTEGER NOT NULL, `display_order` INTEGER NOT NULL DEFAULT 0)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "keyCode",
19 | "columnName": "key_code",
20 | "affinity": "INTEGER",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "mods",
25 | "columnName": "mods",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "name",
31 | "columnName": "name",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "isFavoured",
37 | "columnName": "favoured",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "order",
43 | "columnName": "display_order",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "autoGenerate": true,
51 | "columnNames": [
52 | "_id"
53 | ]
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | },
58 | {
59 | "tableName": "event_action",
60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `action_type` INTEGER NOT NULL DEFAULT 0, `action_id` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
61 | "fields": [
62 | {
63 | "fieldPath": "eventId",
64 | "columnName": "_id",
65 | "affinity": "INTEGER",
66 | "notNull": true
67 | },
68 | {
69 | "fieldPath": "action.actionType",
70 | "columnName": "action_type",
71 | "affinity": "INTEGER",
72 | "notNull": true,
73 | "defaultValue": "0"
74 | },
75 | {
76 | "fieldPath": "action.actionId",
77 | "columnName": "action_id",
78 | "affinity": "INTEGER",
79 | "notNull": true
80 | }
81 | ],
82 | "primaryKey": {
83 | "autoGenerate": false,
84 | "columnNames": [
85 | "_id"
86 | ]
87 | },
88 | "indices": [],
89 | "foreignKeys": []
90 | }
91 | ],
92 | "views": [],
93 | "setupQueries": [
94 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
95 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7244cde83b348567562269f460253824')"
96 | ]
97 | }
98 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/AppNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import androidx.compose.material3.SnackbarDuration
4 | import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.navigation.NavHostController
8 | import androidx.navigation.compose.NavHost
9 | import androidx.navigation.compose.rememberNavController
10 | import androidx.window.core.layout.WindowHeightSizeClass
11 | import io.github.soundremote.ui.about.aboutScreen
12 | import io.github.soundremote.ui.about.navigateToAbout
13 | import io.github.soundremote.ui.events.eventsScreen
14 | import io.github.soundremote.ui.events.navigateToEvents
15 | import io.github.soundremote.ui.home.HomeRoute
16 | import io.github.soundremote.ui.home.homeScreen
17 | import io.github.soundremote.ui.hotkey.hotkeyCreateScreen
18 | import io.github.soundremote.ui.hotkey.hotkeyEditScreen
19 | import io.github.soundremote.ui.hotkey.navigateToHotkeyCreate
20 | import io.github.soundremote.ui.hotkey.navigateToHotkeyEdit
21 | import io.github.soundremote.ui.hotkeylist.hotkeyListScreen
22 | import io.github.soundremote.ui.hotkeylist.navigateToHotkeyList
23 | import io.github.soundremote.ui.settings.navigateToSettings
24 | import io.github.soundremote.ui.settings.settingsScreen
25 |
26 | @Composable
27 | fun AppNavigation(
28 | modifier: Modifier = Modifier,
29 | navController: NavHostController = rememberNavController(),
30 | showSnackbar: (String, SnackbarDuration) -> Unit,
31 | ) {
32 | val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
33 | val compactHeight = windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT
34 | NavHost(
35 | navController = navController,
36 | startDestination = HomeRoute,
37 | modifier = modifier
38 | ) {
39 | homeScreen(
40 | onNavigateToHotkeyList = navController::navigateToHotkeyList,
41 | onNavigateToEvents = navController::navigateToEvents,
42 | onNavigateToSettings = navController::navigateToSettings,
43 | onNavigateToAbout = navController::navigateToAbout,
44 | onNavigateToEditHotkey = { hotkeyId ->
45 | navController.navigateToHotkeyEdit(hotkeyId)
46 | },
47 | showSnackbar = showSnackbar,
48 | )
49 | hotkeyListScreen(
50 | onNavigateToHotkeyCreate = navController::navigateToHotkeyCreate,
51 | onNavigateToHotkeyEdit = { hotkeyId ->
52 | navController.navigateToHotkeyEdit(hotkeyId)
53 | },
54 | onNavigateUp = navController::navigateUp,
55 | )
56 | hotkeyCreateScreen(
57 | onNavigateUp = navController::navigateUp,
58 | showSnackbar = showSnackbar,
59 | compactHeight = compactHeight,
60 | )
61 | hotkeyEditScreen(
62 | onNavigateUp = navController::navigateUp,
63 | showSnackbar = showSnackbar,
64 | compactHeight = compactHeight,
65 | )
66 | eventsScreen(
67 | onNavigateUp = navController::navigateUp,
68 | )
69 | settingsScreen(
70 | onNavigateUp = navController::navigateUp,
71 | )
72 | aboutScreen(
73 | onNavigateUp = navController::navigateUp,
74 | )
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/hotkey/HotkeyNavigation.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkey
2 |
3 | import androidx.compose.material3.SnackbarDuration
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.getValue
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.saveable.rememberSaveable
9 | import androidx.compose.runtime.setValue
10 | import androidx.hilt.navigation.compose.hiltViewModel
11 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
12 | import androidx.navigation.NavController
13 | import androidx.navigation.NavGraphBuilder
14 | import androidx.navigation.compose.composable
15 | import androidx.navigation.toRoute
16 | import io.github.soundremote.util.ModKey
17 | import kotlinx.serialization.Serializable
18 |
19 | @Serializable
20 | object HotkeyCreateRoute
21 |
22 | @Serializable
23 | data class HotkeyEditRoute(val hotkeyId: Int)
24 |
25 | fun NavController.navigateToHotkeyCreate() {
26 | navigate(HotkeyCreateRoute)
27 | }
28 |
29 | fun NavController.navigateToHotkeyEdit(hotkeyId: Int) {
30 | navigate(HotkeyEditRoute(hotkeyId))
31 | }
32 |
33 | fun NavGraphBuilder.hotkeyCreateScreen(
34 | onNavigateUp: () -> Unit,
35 | showSnackbar: (String, SnackbarDuration) -> Unit,
36 | compactHeight: Boolean,
37 | ) {
38 | composable {
39 | HotkeyScreenRoute(
40 | onNavigateUp = onNavigateUp,
41 | showSnackbar = showSnackbar,
42 | compactHeight = compactHeight
43 | )
44 | }
45 | }
46 |
47 | fun NavGraphBuilder.hotkeyEditScreen(
48 | onNavigateUp: () -> Unit,
49 | showSnackbar: (String, SnackbarDuration) -> Unit,
50 | compactHeight: Boolean,
51 | ) {
52 | composable { backStackEntry ->
53 | val route: HotkeyEditRoute = backStackEntry.toRoute()
54 | HotkeyScreenRoute(
55 | hotkeyId = route.hotkeyId,
56 | onNavigateUp = onNavigateUp,
57 | showSnackbar = showSnackbar,
58 | compactHeight = compactHeight
59 | )
60 | }
61 | }
62 |
63 | @Composable
64 | private fun HotkeyScreenRoute(
65 | hotkeyId: Int? = null,
66 | onNavigateUp: () -> Unit,
67 | showSnackbar: (String, SnackbarDuration) -> Unit,
68 | compactHeight: Boolean,
69 | viewModel: HotkeyViewModel = hiltViewModel()
70 | ) {
71 | var needToLoadHotkey by rememberSaveable {
72 | mutableStateOf(hotkeyId != null)
73 | }
74 | LaunchedEffect(Unit) {
75 | if (needToLoadHotkey) {
76 | needToLoadHotkey = false
77 | hotkeyId?.let { viewModel.loadHotkey(it) }
78 | }
79 | }
80 | val state by viewModel.hotkeyScreenState.collectAsStateWithLifecycle()
81 | HotkeyScreen(
82 | state = state,
83 | onKeyCodeChange = { viewModel.updateKeyCode(it) },
84 | onWinChange = { viewModel.updateMod(ModKey.WIN, it) },
85 | onCtrlChange = { viewModel.updateMod(ModKey.CTRL, it) },
86 | onShiftChange = { viewModel.updateMod(ModKey.SHIFT, it) },
87 | onAltChange = { viewModel.updateMod(ModKey.ALT, it) },
88 | onNameChange = { viewModel.updateName(it) },
89 | checkCanSave = viewModel::canSave,
90 | onSave = viewModel::saveHotkey,
91 | onClose = onNavigateUp,
92 | showSnackbar = showSnackbar,
93 | compactHeight = compactHeight,
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/data/preferences/TestPreferencesRepository.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data.preferences
2 |
3 | import io.github.soundremote.util.DEFAULT_AUDIO_COMPRESSION
4 | import io.github.soundremote.util.DEFAULT_CLIENT_PORT
5 | import io.github.soundremote.util.DEFAULT_IGNORE_AUDIO_FOCUS
6 | import io.github.soundremote.util.DEFAULT_SERVER_ADDRESS
7 | import io.github.soundremote.util.DEFAULT_SERVER_PORT
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.MutableStateFlow
10 | import kotlinx.coroutines.flow.first
11 |
12 | class TestPreferencesRepository : PreferencesRepository {
13 | private val _settingsScreenPreferencesFlow = MutableStateFlow(
14 | SettingsScreenPreferences(
15 | DEFAULT_SERVER_PORT,
16 | DEFAULT_CLIENT_PORT,
17 | DEFAULT_AUDIO_COMPRESSION,
18 | DEFAULT_IGNORE_AUDIO_FOCUS,
19 | )
20 | )
21 | override val settingsScreenPreferencesFlow: Flow
22 | get() = _settingsScreenPreferencesFlow
23 |
24 | private val serverAddressesLimit = 5
25 | private val _serverAddressesFlow = MutableStateFlow(listOf(DEFAULT_SERVER_ADDRESS))
26 | override val serverAddressesFlow: Flow>
27 | get() = _serverAddressesFlow
28 |
29 | private val _audioCompressionFlow = MutableStateFlow(DEFAULT_AUDIO_COMPRESSION)
30 | override val audioCompressionFlow: Flow
31 | get() = _audioCompressionFlow
32 |
33 | private val _ignoreAudioFocusFlow = MutableStateFlow(DEFAULT_IGNORE_AUDIO_FOCUS)
34 | override val ignoreAudioFocusFlow: Flow
35 | get() = _ignoreAudioFocusFlow
36 |
37 | override suspend fun setServerAddress(serverAddress: String) {
38 | val current = LinkedHashSet(serverAddressesFlow.first())
39 | current.remove(serverAddress)
40 | current.add(serverAddress)
41 | while (current.size > serverAddressesLimit) {
42 | current.remove(current.first())
43 | }
44 | _serverAddressesFlow.value = current.toList()
45 | }
46 |
47 | override suspend fun getServerAddress(): String {
48 | return _serverAddressesFlow.value.last()
49 | }
50 |
51 | override suspend fun setServerPort(value: Int) {
52 | _settingsScreenPreferencesFlow.value =
53 | _settingsScreenPreferencesFlow.value.copy(serverPort = value)
54 | }
55 |
56 | override suspend fun getServerPort(): Int {
57 | return _settingsScreenPreferencesFlow.value.serverPort
58 | }
59 |
60 | override suspend fun setClientPort(value: Int) {
61 | _settingsScreenPreferencesFlow.value =
62 | _settingsScreenPreferencesFlow.value.copy(clientPort = value)
63 | }
64 |
65 | override suspend fun getClientPort(): Int {
66 | return _settingsScreenPreferencesFlow.value.clientPort
67 | }
68 |
69 | override suspend fun setAudioCompression(value: Int) {
70 | _settingsScreenPreferencesFlow.value =
71 | _settingsScreenPreferencesFlow.value.copy(audioCompression = value)
72 | _audioCompressionFlow.value = value
73 | }
74 |
75 | override suspend fun getAudioCompression(): Int {
76 | return _audioCompressionFlow.value
77 | }
78 |
79 | override suspend fun setIgnoreAudioFocus(value: Boolean) {
80 | _settingsScreenPreferencesFlow.value =
81 | _settingsScreenPreferencesFlow.value.copy(ignoreAudioFocus = value)
82 | _ignoreAudioFocusFlow.value = value
83 | }
84 |
85 | override suspend fun getIgnoreAudioFocus(): Boolean {
86 | return _settingsScreenPreferencesFlow.value.ignoreAudioFocus
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/data/Hotkey.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.data
2 |
3 | import android.provider.BaseColumns
4 | import androidx.room.ColumnInfo
5 | import androidx.room.Entity
6 | import androidx.room.Ignore
7 | import androidx.room.PrimaryKey
8 | import io.github.soundremote.util.KeyCode
9 | import io.github.soundremote.util.Mods
10 | import io.github.soundremote.util.generateDescription
11 |
12 | @Entity(tableName = Hotkey.TABLE_NAME)
13 | data class Hotkey(
14 | @PrimaryKey(autoGenerate = true)
15 | @ColumnInfo(name = COLUMN_ID)
16 | var id: Int = 0,
17 | @ColumnInfo(name = COLUMN_KEY_CODE)
18 | var keyCode: KeyCode,
19 | @ColumnInfo(name = COLUMN_MODS)
20 | var mods: Mods,
21 | @ColumnInfo(name = COLUMN_NAME)
22 | var name: String,
23 | @ColumnInfo(name = COLUMN_FAVOURED)
24 | var isFavoured: Boolean,
25 | // Hotkeys are ordered by this number descending, so new items with order value of 0 will
26 | // appear below.
27 | @ColumnInfo(name = COLUMN_ORDER, defaultValue = "$ORDER_DEFAULT_VALUE")
28 | var order: Int,
29 | ) {
30 | /**
31 | * Creates [Hotkey]
32 | *
33 | * @param keyCode Windows Virtual-Key code of the main key
34 | * @param name text description
35 | * @param mods modifier keys
36 | * @param favoured should the hotkey be visible on the home screen
37 | */
38 | @Ignore
39 | constructor(keyCode: KeyCode, name: String, mods: Mods? = null, favoured: Boolean = true) :
40 | this(
41 | keyCode = keyCode,
42 | name = name,
43 | mods = mods ?: Mods(0),
44 | isFavoured = favoured,
45 | order = ORDER_DEFAULT_VALUE,
46 | )
47 |
48 | override fun toString(): String {
49 | val isFav = if (isFavoured) "Yes" else "No"
50 | return "${generateDescription(this)} (Title: $name, favoured: $isFav)"
51 | }
52 |
53 | override fun equals(other: Any?): Boolean {
54 | if (this === other) return true
55 | if (javaClass != other?.javaClass) return false
56 | other as Hotkey
57 | if (id != other.id) return false
58 | if (keyCode != other.keyCode) return false
59 | if (mods != other.mods) return false
60 | if (name != other.name) return false
61 | return isFavoured == other.isFavoured
62 | }
63 |
64 | override fun hashCode(): Int {
65 | var result = id
66 | result = 31 * result + keyCode.value
67 | result = 31 * result + mods.value
68 | result = 31 * result + name.hashCode()
69 | result = 31 * result + isFavoured.hashCode()
70 | result = 31 * result + order
71 | return result
72 | }
73 |
74 | companion object {
75 | const val TABLE_NAME = "hotkey"
76 | const val COLUMN_ID = BaseColumns._ID
77 | const val COLUMN_KEY_CODE = "key_code"
78 | const val COLUMN_MODS = "mods"
79 | const val COLUMN_NAME = "name"
80 | const val COLUMN_FAVOURED = "favoured"
81 | const val COLUMN_ORDER = "display_order"
82 | const val ORDER_DEFAULT_VALUE = 0
83 | }
84 | }
85 |
86 | data class HotkeyInfo(
87 | @ColumnInfo(name = Hotkey.COLUMN_ID)
88 | var id: Int,
89 | @ColumnInfo(name = Hotkey.COLUMN_KEY_CODE)
90 | var keyCode: KeyCode,
91 | @ColumnInfo(name = Hotkey.COLUMN_MODS)
92 | var mods: Mods,
93 | @ColumnInfo(name = Hotkey.COLUMN_NAME)
94 | var name: String,
95 | )
96 |
97 | data class HotkeyOrder(
98 | @ColumnInfo(name = Hotkey.COLUMN_ID)
99 | var id: Int,
100 | @ColumnInfo(name = Hotkey.COLUMN_ORDER)
101 | var order: Int,
102 | )
103 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/ui/settings/SelectPreferenceTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.activity.ComponentActivity
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.test.assertCountEquals
6 | import androidx.compose.ui.test.assertIsNotSelected
7 | import androidx.compose.ui.test.assertIsSelected
8 | import androidx.compose.ui.test.isSelectable
9 | import androidx.compose.ui.test.isSelected
10 | import androidx.compose.ui.test.junit4.createAndroidComposeRule
11 | import androidx.compose.ui.test.onNodeWithTag
12 | import androidx.compose.ui.test.onNodeWithText
13 | import androidx.compose.ui.test.performClick
14 | import io.github.soundremote.R
15 | import org.junit.Assert.assertEquals
16 | import org.junit.Rule
17 | import org.junit.Test
18 |
19 | class SelectPreferenceTest {
20 |
21 | @get:Rule
22 | val composeRule = createAndroidComposeRule()
23 |
24 | private val testOptions: List> = List(100) {
25 | val id = it + 1
26 | SelectableOption(id, R.string.compression_320)
27 | }
28 |
29 | @Test
30 | fun selectedOption_notInOptionsList_nothingIsSelected() {
31 | val title = "Preference title"
32 | composeRule.setContent {
33 | CreateSelectPreference(
34 | selected = 1_234_456,
35 | title = title,
36 | options = testOptions,
37 | )
38 | }
39 |
40 | composeRule.apply {
41 | onNodeWithText(title).performClick()
42 | onAllNodes(isSelectable() and isSelected()).assertCountEquals(0)
43 | }
44 | }
45 |
46 | @Test
47 | fun selectedOption_inOptionsList_isSelected() {
48 | val title = "Preference title"
49 | val expectedValue = testOptions.last().value
50 | composeRule.setContent {
51 | CreateSelectPreference(
52 | selected = expectedValue,
53 | title = title,
54 | options = testOptions,
55 | )
56 | }
57 |
58 | composeRule.apply {
59 | onNodeWithText(title).performClick()
60 | onNodeWithTag("selectable:$expectedValue").assertIsSelected()
61 | }
62 | }
63 |
64 | @Test
65 | fun selectDialogOption_onClick_invokesCallback() {
66 | val title = "Preference title"
67 | val expectedValue = testOptions[1].value
68 | var actual = -1
69 | composeRule.setContent {
70 | CreateSelectPreference(
71 | selected = testOptions[0].value,
72 | title = title,
73 | options = testOptions,
74 | onSelect = { actual = it }
75 | )
76 | }
77 |
78 | composeRule.apply {
79 | onNodeWithText(title).performClick()
80 | onNodeWithTag("selectable:$expectedValue").apply {
81 | assertIsNotSelected()
82 | performClick()
83 | }
84 | }
85 |
86 | assertEquals(expectedValue, actual)
87 | }
88 |
89 | @Suppress("TestFunctionName")
90 | @Composable
91 | private fun CreateSelectPreference(
92 | selected: T,
93 | title: String = "Preference title",
94 | summary: String = "Preference summary",
95 | options: List> = emptyList(),
96 | onSelect: (T) -> Unit = {},
97 | ) {
98 | SelectPreference(
99 | title = title,
100 | summary = summary,
101 | options = options,
102 | selectedValue = selected,
103 | onSelect = onSelect,
104 | )
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/app/schemas/io.github.soundremote.data.room.AppDatabase/1.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 1,
5 | "identityHash": "ad16f1477c29c8e90da71b2fdbf69fab",
6 | "entities": [
7 | {
8 | "tableName": "keystroke",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `key_code` INTEGER NOT NULL, `mods` INTEGER NOT NULL, `name` TEXT NOT NULL, `favoured` INTEGER NOT NULL, `display_order` INTEGER NOT NULL DEFAULT 0)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": true
16 | },
17 | {
18 | "fieldPath": "keyCode",
19 | "columnName": "key_code",
20 | "affinity": "INTEGER",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "mods",
25 | "columnName": "mods",
26 | "affinity": "INTEGER",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "name",
31 | "columnName": "name",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "isFavoured",
37 | "columnName": "favoured",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "order",
43 | "columnName": "display_order",
44 | "affinity": "INTEGER",
45 | "notNull": true,
46 | "defaultValue": "0"
47 | }
48 | ],
49 | "primaryKey": {
50 | "autoGenerate": true,
51 | "columnNames": [
52 | "_id"
53 | ]
54 | },
55 | "indices": [],
56 | "foreignKeys": []
57 | },
58 | {
59 | "tableName": "event_action",
60 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER NOT NULL, `keystroke_id` INTEGER NOT NULL, PRIMARY KEY(`_id`), FOREIGN KEY(`keystroke_id`) REFERENCES `keystroke`(`_id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
61 | "fields": [
62 | {
63 | "fieldPath": "eventId",
64 | "columnName": "_id",
65 | "affinity": "INTEGER",
66 | "notNull": true
67 | },
68 | {
69 | "fieldPath": "keystrokeId",
70 | "columnName": "keystroke_id",
71 | "affinity": "INTEGER",
72 | "notNull": true
73 | }
74 | ],
75 | "primaryKey": {
76 | "autoGenerate": false,
77 | "columnNames": [
78 | "_id"
79 | ]
80 | },
81 | "indices": [
82 | {
83 | "name": "index_event_action_keystroke_id",
84 | "unique": false,
85 | "columnNames": [
86 | "keystroke_id"
87 | ],
88 | "orders": [],
89 | "createSql": "CREATE INDEX IF NOT EXISTS `index_event_action_keystroke_id` ON `${TABLE_NAME}` (`keystroke_id`)"
90 | }
91 | ],
92 | "foreignKeys": [
93 | {
94 | "table": "keystroke",
95 | "onDelete": "CASCADE",
96 | "onUpdate": "NO ACTION",
97 | "columns": [
98 | "keystroke_id"
99 | ],
100 | "referencedColumns": [
101 | "_id"
102 | ]
103 | }
104 | ]
105 | }
106 | ],
107 | "views": [],
108 | "setupQueries": [
109 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
110 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ad16f1477c29c8e90da71b2fdbf69fab')"
111 | ]
112 | }
113 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/home/HomeViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.home
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateOf
6 | import androidx.compose.runtime.setValue
7 | import androidx.lifecycle.ViewModel
8 | import androidx.lifecycle.viewModelScope
9 | import io.github.soundremote.R
10 | import io.github.soundremote.data.HotkeyRepository
11 | import io.github.soundremote.data.preferences.PreferencesRepository
12 | import io.github.soundremote.service.ServiceManager
13 | import io.github.soundremote.util.ConnectionStatus
14 | import io.github.soundremote.util.Key
15 | import io.github.soundremote.util.HotkeyDescription
16 | import io.github.soundremote.util.generateDescription
17 | import com.google.common.net.InetAddresses
18 | import dagger.hilt.android.lifecycle.HiltViewModel
19 | import kotlinx.coroutines.flow.SharingStarted
20 | import kotlinx.coroutines.flow.StateFlow
21 | import kotlinx.coroutines.flow.combine
22 | import kotlinx.coroutines.flow.stateIn
23 | import kotlinx.coroutines.launch
24 | import javax.inject.Inject
25 |
26 | data class HomeUIState(
27 | val hotkeys: List = emptyList(),
28 | val serverAddress: String = "",
29 | val recentServersAddresses: List = emptyList(),
30 | val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
31 | val isMuted: Boolean = false,
32 | )
33 |
34 | data class HomeHotkeyUIState(
35 | val id: Int,
36 | val name: String,
37 | val description: HotkeyDescription,
38 | )
39 |
40 | @HiltViewModel
41 | internal class HomeViewModel @Inject constructor(
42 | private val userPreferencesRepo: PreferencesRepository,
43 | private val hotkeyRepository: HotkeyRepository,
44 | private val serviceManager: ServiceManager,
45 | ) : ViewModel() {
46 |
47 | val homeUIState: StateFlow = combine(
48 | hotkeyRepository.getFavouredOrdered(true),
49 | userPreferencesRepo.serverAddressesFlow,
50 | serviceManager.serviceState,
51 | ) { hotkeys, addresses, serviceState ->
52 | val hotkeyStates = hotkeys.map { hotkey ->
53 | HomeHotkeyUIState(
54 | id = hotkey.id,
55 | name = hotkey.name,
56 | description = generateDescription(
57 | keyCode = hotkey.keyCode,
58 | mods = hotkey.mods
59 | ),
60 | )
61 | }
62 | HomeUIState(
63 | hotkeys = hotkeyStates,
64 | serverAddress = addresses.last(),
65 | recentServersAddresses = addresses,
66 | connectionStatus = serviceState.connectionStatus,
67 | isMuted = serviceState.isMuted,
68 | )
69 | }.stateIn(
70 | scope = viewModelScope,
71 | started = SharingStarted.WhileSubscribed(5_000),
72 | initialValue = HomeUIState()
73 | )
74 | var messageState by mutableStateOf(null)
75 | private set
76 |
77 | private fun setServerAddress(address: String) {
78 | viewModelScope.launch {
79 | userPreferencesRepo.setServerAddress(address)
80 | }
81 | }
82 |
83 | private fun setMessage(@StringRes messageId: Int) {
84 | messageState = messageId
85 | }
86 |
87 | fun messageShown() {
88 | messageState = null
89 | }
90 |
91 | fun connect(address: String) {
92 | val newAddress = address.trim()
93 | if (InetAddresses.isInetAddress(newAddress)) {
94 | setServerAddress(newAddress)
95 | serviceManager.connect(newAddress)
96 | } else {
97 | setMessage(R.string.message_invalid_address)
98 | }
99 | }
100 |
101 | fun disconnect() {
102 | serviceManager.disconnect()
103 | }
104 |
105 | fun sendHotkey(hotkeyId: Int) {
106 | viewModelScope.launch {
107 | hotkeyRepository.getById(hotkeyId)?.let {
108 | serviceManager.sendHotkey(it)
109 | }
110 | }
111 | }
112 |
113 | fun sendKey(key: Key) {
114 | serviceManager.sendKey(key)
115 | }
116 |
117 | fun setMuted(value: Boolean) {
118 | serviceManager.setMuted(value)
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/ui/hotkey/HotkeyFlowTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkey
2 |
3 | import androidx.compose.ui.test.junit4.createAndroidComposeRule
4 | import androidx.compose.ui.test.onNodeWithContentDescription
5 | import androidx.compose.ui.test.onNodeWithText
6 | import androidx.compose.ui.test.performClick
7 | import androidx.compose.ui.test.performScrollTo
8 | import androidx.compose.ui.test.performTextInput
9 | import dagger.hilt.android.testing.BindValue
10 | import dagger.hilt.android.testing.HiltAndroidRule
11 | import dagger.hilt.android.testing.HiltAndroidTest
12 | import io.github.soundremote.MainActivity
13 | import io.github.soundremote.R
14 | import io.github.soundremote.stringResource
15 | import org.junit.Rule
16 | import org.junit.Test
17 | import org.junit.rules.TemporaryFolder
18 |
19 | @HiltAndroidTest
20 | class HotkeyFlowTest {
21 |
22 | @get:Rule(order = 0)
23 | val hiltRule = HiltAndroidRule(this)
24 |
25 | @BindValue
26 | @get:Rule(order = 1)
27 | val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
28 |
29 | @get:Rule(order = 2)
30 | val composeTestRule = createAndroidComposeRule()
31 |
32 | private val editHotkeys by composeTestRule.stringResource(R.string.action_edit_hotkeys)
33 | private val createHotkey by composeTestRule.stringResource(R.string.action_hotkey_create)
34 | private val altMod by composeTestRule.stringResource(R.string.alt_checkbox_label)
35 | private val ctrlMod by composeTestRule.stringResource(R.string.ctrl_checkbox_label)
36 | private val hotkeyEdit by composeTestRule.stringResource(R.string.hotkey_key_edit_label)
37 | private val save by composeTestRule.stringResource(R.string.save)
38 | private val name by composeTestRule.stringResource(R.string.hotkey_name_edit_label)
39 | private val clear by composeTestRule.stringResource(R.string.clear)
40 |
41 | @Test
42 | fun createAndEditHotkeyFlow() {
43 | // Go to Create Hotkey screen
44 | composeTestRule.apply {
45 | onNodeWithContentDescription(editHotkeys).performClick()
46 | onNodeWithContentDescription(createHotkey).performClick()
47 | }
48 |
49 | // Create hotkey
50 | val originalName = "My name"
51 | val originalKey = "y"
52 | composeTestRule.apply {
53 | // Perform non-text actions first because performClick doesn't work if composable is
54 | // covered by ime
55 | onNodeWithText(altMod).performScrollTo()
56 | onNodeWithText(altMod).performClick()
57 |
58 | onNodeWithText(hotkeyEdit).performTextInput(originalKey)
59 | onNodeWithText(name).performTextInput(originalName)
60 |
61 | onNodeWithContentDescription(save).performClick()
62 | }
63 |
64 | // Assert created
65 | composeTestRule.onNodeWithText(originalName).assertExists()
66 | composeTestRule.onNodeWithText(
67 | text = altMod,
68 | substring = true,
69 | ignoreCase = true,
70 | ).assertExists()
71 |
72 | // Edit
73 | val editedName = "New name"
74 | val editedKey = "m"
75 | composeTestRule.apply {
76 | onNodeWithText(originalName).performClick()
77 |
78 | // Non-text actions
79 | onNodeWithText(altMod).performScrollTo()
80 | onNodeWithText(altMod).performClick()
81 | onNodeWithText(ctrlMod).performScrollTo()
82 | onNodeWithText(ctrlMod).performClick()
83 |
84 | onNodeWithText(hotkeyEdit).performTextInput(editedKey)
85 | onNodeWithText(name).performScrollTo()
86 | onNodeWithContentDescription(clear).performClick()
87 | onNodeWithText(name).performTextInput(editedName)
88 |
89 | onNodeWithContentDescription(save).performClick()
90 | }
91 |
92 | // Assert edited
93 | composeTestRule.onNodeWithText(editedName).assertExists()
94 | composeTestRule.onNodeWithText(
95 | text = ctrlMod,
96 | substring = true,
97 | ignoreCase = true,
98 | ).assertExists()
99 | composeTestRule.onNodeWithText(
100 | text = altMod,
101 | substring = true,
102 | ignoreCase = true,
103 | ).assertDoesNotExist()
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/events/EventsViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.events
2 |
3 | import android.os.Build
4 | import androidx.annotation.StringRes
5 | import androidx.lifecycle.ViewModel
6 | import androidx.lifecycle.viewModelScope
7 | import io.github.soundremote.data.ActionData
8 | import io.github.soundremote.data.Action
9 | import io.github.soundremote.data.ActionType
10 | import io.github.soundremote.data.AppAction
11 | import io.github.soundremote.data.Event
12 | import io.github.soundremote.data.EventAction
13 | import io.github.soundremote.data.EventActionRepository
14 | import io.github.soundremote.data.HotkeyRepository
15 | import io.github.soundremote.util.AppPermission
16 | import io.github.soundremote.util.TextValue
17 | import dagger.hilt.android.lifecycle.HiltViewModel
18 | import kotlinx.coroutines.flow.SharingStarted
19 | import kotlinx.coroutines.flow.StateFlow
20 | import kotlinx.coroutines.flow.combine
21 | import kotlinx.coroutines.flow.flowOf
22 | import kotlinx.coroutines.flow.stateIn
23 | import kotlinx.coroutines.launch
24 | import javax.inject.Inject
25 |
26 | internal data class EventUIState(
27 | val id: Int,
28 | @StringRes
29 | val nameStringId: Int,
30 | val permission: AppPermission? = null,
31 | val action: ActionUIState? = null,
32 | val applicableActionTypes: Set,
33 | )
34 |
35 | internal data class ActionUIState(
36 | val type: ActionType,
37 | val id: Int,
38 | val name: TextValue,
39 | )
40 |
41 | internal data class EventsUIState(
42 | val events: List = emptyList()
43 | )
44 |
45 | @HiltViewModel
46 | internal class EventsViewModel @Inject constructor(
47 | private val eventActionRepository: EventActionRepository,
48 | private val hotkeyRepository: HotkeyRepository,
49 | ) : ViewModel() {
50 | val uiState: StateFlow = combine(
51 | flowOf(Event.entries.toTypedArray()),
52 | eventActionRepository.getAll(),
53 | ) { events, eventActions ->
54 | val eventUIStates = mutableListOf()
55 | for (event in events) {
56 | val eventAction = eventActions.find { it.eventId == event.id }
57 | val actionUIState = eventAction?.action?.let { action ->
58 | val type = ActionType.getById(action.actionType)
59 | val name: TextValue = when (type) {
60 | ActionType.APP -> {
61 | TextValue.TextResource(AppAction.getById(action.actionId).nameStringId)
62 | }
63 |
64 | ActionType.HOTKEY -> {
65 | TextValue.TextString(hotkeyRepository.getById(action.actionId)!!.name)
66 | }
67 | }
68 | ActionUIState(type, action.actionId, name)
69 | }
70 | val permission = if (
71 | (event.permissionMinSdk == null) ||
72 | (event.permissionMinSdk <= Build.VERSION.SDK_INT)
73 | ) {
74 | event.requiredPermission
75 | } else {
76 | null
77 | }
78 | eventUIStates.add(
79 | EventUIState(
80 | id = event.id,
81 | nameStringId = event.nameStringId,
82 | permission = permission,
83 | action = actionUIState,
84 | applicableActionTypes = event.applicableActionTypes,
85 | )
86 | )
87 | }
88 | EventsUIState(eventUIStates)
89 | }.stateIn(
90 | scope = viewModelScope,
91 | started = SharingStarted.WhileSubscribed(5_000),
92 | initialValue = EventsUIState()
93 | )
94 |
95 | fun setActionForEvent(eventId: Int, action: Action?) {
96 | viewModelScope.launch {
97 | if (action == null) {
98 | eventActionRepository.deleteById(eventId)
99 | } else {
100 | val actionData = ActionData(action.type.id, action.id)
101 | val event = eventActionRepository.getById(eventId)
102 | if (event == null) {
103 | eventActionRepository.insert(EventAction(eventId, actionData))
104 | } else {
105 | event.action = actionData
106 | eventActionRepository.update(event)
107 | }
108 | }
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/service/MainServiceManager.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.service
2 |
3 | import android.content.ComponentName
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.ServiceConnection
7 | import android.os.IBinder
8 | import io.github.soundremote.data.Hotkey
9 | import io.github.soundremote.util.ConnectionStatus
10 | import io.github.soundremote.util.Key
11 | import io.github.soundremote.util.SystemMessage
12 | import kotlinx.coroutines.CoroutineDispatcher
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.Job
16 | import kotlinx.coroutines.SupervisorJob
17 | import kotlinx.coroutines.channels.BufferOverflow
18 | import kotlinx.coroutines.channels.Channel
19 | import kotlinx.coroutines.channels.ReceiveChannel
20 | import kotlinx.coroutines.flow.MutableStateFlow
21 | import kotlinx.coroutines.flow.StateFlow
22 | import kotlinx.coroutines.flow.combine
23 | import kotlinx.coroutines.isActive
24 | import kotlinx.coroutines.launch
25 | import java.lang.ref.WeakReference
26 | import javax.inject.Inject
27 | import javax.inject.Singleton
28 |
29 | @Singleton
30 | internal class MainServiceManager(
31 | private val dispatcher: CoroutineDispatcher,
32 | ) : ServiceManager {
33 | @Inject
34 | constructor() : this(Dispatchers.Default)
35 |
36 | private val scope = CoroutineScope(SupervisorJob() + dispatcher)
37 | private lateinit var service: WeakReference
38 | private var bound: Boolean = false
39 | private var stateCollect: Job? = null
40 | private var messageCollect: Job? = null
41 | private var _serviceState = MutableStateFlow(ServiceState(ConnectionStatus.DISCONNECTED, false))
42 | override val serviceState: StateFlow
43 | get() = _serviceState
44 |
45 | private val _systemMessages: Channel = Channel(5, BufferOverflow.DROP_OLDEST)
46 | override val systemMessages: ReceiveChannel
47 | get() = _systemMessages
48 |
49 | override fun bind(context: Context) {
50 | Intent(context, MainService::class.java).also { intent ->
51 | context.bindService(intent, serviceConnection, 0)
52 | }
53 | }
54 |
55 | override fun unbind(context: Context) {
56 | stopCollect()
57 | context.unbindService(serviceConnection)
58 | }
59 |
60 | override fun connect(address: String) {
61 | if (!bound) return
62 | service.get()?.connect(address)
63 | }
64 |
65 | override fun disconnect() {
66 | if (!bound) return
67 | service.get()?.disconnect()
68 | }
69 |
70 | override fun sendHotkey(hotkey: Hotkey) {
71 | if (!bound) return
72 | service.get()?.sendHotkey(hotkey)
73 | }
74 |
75 | override fun sendKey(key: Key) {
76 | if (!bound) return
77 | service.get()?.sendKey(key)
78 | }
79 |
80 | override fun setMuted(value: Boolean) {
81 | if (!bound) return
82 | service.get()?.setMuted(value)
83 | }
84 |
85 | private val serviceConnection: ServiceConnection = object : ServiceConnection {
86 | override fun onServiceConnected(name: ComponentName, binder: IBinder) {
87 | val localBinder = binder as MainService.LocalBinder
88 | service = WeakReference(localBinder.getService())
89 | startCollect()
90 | bound = true
91 | }
92 |
93 | override fun onServiceDisconnected(name: ComponentName) {
94 | bound = false
95 | stopCollect()
96 | }
97 | }
98 |
99 | private fun startCollect() {
100 | service.get()?.let { service ->
101 | stateCollect = scope.launch(dispatcher) {
102 | combine(service.connectionStatus, service.isMuted) { connectionStatus, isMuted ->
103 | ServiceState(connectionStatus, isMuted)
104 | }.collect { _serviceState.value = it }
105 | }
106 | messageCollect = scope.launch(dispatcher) {
107 | while (isActive) {
108 | val message = service.systemMessages.receive()
109 | _systemMessages.send(message)
110 | }
111 | }
112 | }
113 | }
114 |
115 | private fun stopCollect() {
116 | stateCollect?.cancel()
117 | stateCollect = null
118 | messageCollect?.cancel()
119 | messageCollect = null
120 | }
121 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/soundremote/audio/decoder/OpusAudioDecoderTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.audio.decoder
2 |
3 | import org.hamcrest.CoreMatchers.equalTo
4 | import org.hamcrest.MatcherAssert.assertThat
5 | import org.junit.Test
6 | import org.junit.experimental.runners.Enclosed
7 | import org.junit.runner.RunWith
8 | import org.junit.runners.Parameterized
9 | import org.junit.runners.Parameterized.Parameters
10 |
11 | @RunWith(Enclosed::class)
12 | internal class OpusAudioDecoderTest {
13 |
14 | @RunWith(Parameterized::class)
15 | internal class ValidArgs_CreatesSuccessfully(private val rate: Int, private val channels: Int) {
16 | companion object {
17 | @JvmStatic
18 | @Parameters(name = "{0} Hz, {1} channel(s)")
19 | fun data(): Collection> {
20 | return listOf(arrayOf(8000, 1), arrayOf(24000, 1), arrayOf(48000, 2))
21 | }
22 | }
23 |
24 | @Test
25 | fun test() {
26 | OpusAudioDecoder(rate, channels)
27 | }
28 | }
29 |
30 | @RunWith(Parameterized::class)
31 | internal class InvalidArgs_ThrowsDecoderException(
32 | private val rate: Int,
33 | private val channels: Int
34 | ) {
35 | companion object {
36 | @JvmStatic
37 | @Parameters(name = "{0} Hz, {1} channel(s)")
38 | fun data(): Collection> {
39 | return listOf(arrayOf(4000, 1), arrayOf(24000, 4), arrayOf(44000, 6))
40 | }
41 | }
42 |
43 | @Test(expected = DecoderException::class)
44 | fun test() {
45 | OpusAudioDecoder(rate, channels)
46 | }
47 | }
48 |
49 | @RunWith(Parameterized::class)
50 | internal class BytesPerPacket_CalculatedCorrectly(
51 | private val rate: Int,
52 | private val channels: Int,
53 | private val duration: Int,
54 | private val expected: Int,
55 | ) {
56 | companion object {
57 | @JvmStatic
58 | @Parameters(name = "{0} Hz, {1} channel(s), {2} packet duration")
59 | fun data(): Collection> {
60 | return listOf(
61 | arrayOf(8_000, 1, 5_000, 80),
62 | arrayOf(24_000, 1, 20_000, 960),
63 | arrayOf(48_000, 2, 10_000, 1_920),
64 | )
65 | }
66 | }
67 |
68 | @Test
69 | fun test() {
70 | val decoder = OpusAudioDecoder(rate, channels, duration)
71 | val actual = decoder.bytesPerPacket
72 |
73 | assertThat(actual, equalTo(expected))
74 | }
75 | }
76 |
77 | @RunWith(Parameterized::class)
78 | internal class FramesPerPacket_CalculatedCorrectly(
79 | private val rate: Int,
80 | private val channels: Int,
81 | private val duration: Int,
82 | private val expected: Int,
83 | ) {
84 | companion object {
85 | @JvmStatic
86 | @Parameters(name = "{0} Hz, {1} channel(s), {2} packet duration")
87 | fun data(): Collection> {
88 | return listOf(
89 | arrayOf(8_000, 1, 5_000, 40),
90 | arrayOf(24_000, 1, 20_000, 480),
91 | arrayOf(48_000, 2, 10_000, 480),
92 | )
93 | }
94 | }
95 |
96 | @Test
97 | fun test() {
98 | val decoder = OpusAudioDecoder(rate, channels, duration)
99 | val actual = decoder.framesPerPacket
100 |
101 | assertThat(actual, equalTo(expected))
102 | }
103 | }
104 |
105 | @RunWith(Parameterized::class)
106 | internal class MaxPacketsPerPlc_CalculatedCorrectly(
107 | private val rate: Int,
108 | private val channels: Int,
109 | private val duration: Int,
110 | private val expected: Int,
111 | ) {
112 | companion object {
113 | @JvmStatic
114 | @Parameters(name = "{0} Hz, {1} channel(s), {2} packet duration")
115 | fun data(): Collection> {
116 | return listOf(
117 | arrayOf(8_000, 1, 5_000, 12),
118 | arrayOf(24_000, 1, 20_000, 3),
119 | arrayOf(48_000, 2, 10_000, 6),
120 | )
121 | }
122 | }
123 |
124 | @Test
125 | fun test() {
126 | val decoder = OpusAudioDecoder(rate, channels, duration)
127 | val actual = decoder.maxPacketsPerPlc
128 |
129 | assertThat(actual, equalTo(expected))
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/settings/SelectPreference.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.settings
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.Spacer
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.width
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.foundation.lazy.items
12 | import androidx.compose.foundation.lazy.rememberLazyListState
13 | import androidx.compose.foundation.selection.selectable
14 | import androidx.compose.foundation.selection.selectableGroup
15 | import androidx.compose.material3.AlertDialog
16 | import androidx.compose.material3.RadioButton
17 | import androidx.compose.material3.Text
18 | import androidx.compose.material3.TextButton
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.runtime.mutableStateOf
22 | import androidx.compose.runtime.saveable.rememberSaveable
23 | import androidx.compose.runtime.setValue
24 | import androidx.compose.ui.Alignment
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.platform.testTag
27 | import androidx.compose.ui.res.stringResource
28 | import androidx.compose.ui.semantics.Role
29 | import androidx.compose.ui.unit.dp
30 | import io.github.soundremote.ui.components.ListItemHeadline
31 |
32 | internal data class SelectableOption(val value: T, @StringRes val textStringId: Int)
33 |
34 | @Composable
35 | internal fun SelectPreference(
36 | title: String,
37 | summary: String,
38 | options: List>,
39 | selectedValue: T,
40 | onSelect: (T) -> Unit,
41 | modifier: Modifier = Modifier,
42 | ) {
43 | var showDialog by rememberSaveable { mutableStateOf(false) }
44 | PreferenceItem(
45 | title = title,
46 | summary = summary,
47 | onClick = { showDialog = true },
48 | modifier = modifier,
49 | )
50 | if (showDialog) {
51 | val dismiss = { showDialog = false }
52 | AlertDialog(
53 | title = {
54 | Text(text = title)
55 | },
56 | text = {
57 | Options(
58 | options = options,
59 | selected = selectedValue,
60 | onSelect = {
61 | onSelect(it)
62 | dismiss.invoke()
63 | },
64 | )
65 | },
66 | confirmButton = {
67 | TextButton(
68 | onClick = dismiss
69 | ) {
70 | Text(stringResource(io.github.soundremote.R.string.cancel))
71 | }
72 | },
73 | onDismissRequest = dismiss,
74 | )
75 | }
76 | }
77 |
78 | @Composable
79 | private fun Options(
80 | options: List>,
81 | selected: T,
82 | onSelect: (T) -> Unit,
83 | modifier: Modifier = Modifier,
84 | ) {
85 | LazyColumn(
86 | state = rememberLazyListState(
87 | options.indexOfFirst { it.value == selected }
88 | .coerceAtLeast(0)
89 | ),
90 | modifier = modifier
91 | .padding(top = 8.dp, bottom = 8.dp),
92 | ) {
93 | items(
94 | items = options,
95 | key = { it.value },
96 | ) { option ->
97 | OptionItem(
98 | text = stringResource(option.textStringId),
99 | selected = option.value == selected,
100 | onClick = { onSelect(option.value) },
101 | modifier = Modifier.testTag("selectable:${option.value}"),
102 | )
103 | }
104 | }
105 | }
106 |
107 | @Composable
108 | private fun OptionItem(
109 | text: String,
110 | selected: Boolean,
111 | onClick: () -> Unit,
112 | modifier: Modifier = Modifier,
113 | ) {
114 | Row(
115 | modifier = modifier
116 | .selectable(
117 | selected = selected,
118 | onClick = onClick,
119 | role = Role.RadioButton,
120 | )
121 | .height(56.dp)
122 | .fillMaxWidth()
123 | .selectableGroup(),
124 | verticalAlignment = Alignment.CenterVertically,
125 | ) {
126 | Spacer(Modifier.width(16.dp))
127 | RadioButton(
128 | selected = selected,
129 | onClick = null,
130 | )
131 | Spacer(Modifier.width(16.dp))
132 | ListItemHeadline(text = text)
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/hotkeylist/ListDragState.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkeylist
2 |
3 | import androidx.compose.foundation.lazy.LazyListItemInfo
4 | import androidx.compose.foundation.lazy.LazyListState
5 | import androidx.compose.foundation.lazy.rememberLazyListState
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.Stable
8 | import androidx.compose.runtime.derivedStateOf
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableFloatStateOf
11 | import androidx.compose.runtime.mutableIntStateOf
12 | import androidx.compose.runtime.mutableStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.runtime.setValue
15 |
16 | @Composable
17 | fun rememberListDragState(
18 | key: Any?,
19 | onMove: (from: Int, to: Int) -> Unit,
20 | onFirstVisibleItemChange: (VisibleItemInfo) -> Unit,
21 | listState: LazyListState = rememberLazyListState(),
22 | ): ListDragState {
23 | return remember(key) {
24 | ListDragState(
25 | listState = listState,
26 | onMove = onMove,
27 | onFirstVisibleItemChange = onFirstVisibleItemChange,
28 | )
29 | }
30 | }
31 |
32 | sealed interface DragState {
33 | data object Dragged : DragState
34 | data class Shifted(val offset: Int) : DragState
35 | data object Default : DragState
36 | }
37 |
38 | @Stable
39 | class ListDragState(
40 | private val listState: LazyListState,
41 | private val onMove: (from: Int, to: Int) -> Unit,
42 | private val onFirstVisibleItemChange: (VisibleItemInfo) -> Unit,
43 | ) {
44 | private var draggedItemInfo: LazyListItemInfo? by mutableStateOf(null)
45 | private var draggedDistance: Float by mutableFloatStateOf(0f)
46 |
47 | var draggedItemIndex by mutableIntStateOf(-1)
48 | private set
49 |
50 | var shiftedState: DragState by mutableStateOf(DragState.Shifted(0))
51 | private set
52 |
53 | // Items that are currently shifted by the dragged item.
54 | val shiftedItemsIndices by derivedStateOf {
55 | val draggedItem = draggedItemInfo ?: return@derivedStateOf IntRange.EMPTY
56 | val visibleItems = listState.layoutInfo.visibleItemsInfo
57 | val draggedOffsetTotal = draggedItem.offset + draggedDistance
58 | if (draggedDistance > 0) {
59 | var currentVisibleItemIndex = visibleItems.lastIndex
60 | while (
61 | currentVisibleItemIndex > 0 &&
62 | visibleItems[currentVisibleItemIndex].index > draggedItem.index &&
63 | visibleItems[currentVisibleItemIndex].offset > draggedOffsetTotal
64 | ) {
65 | currentVisibleItemIndex--
66 | }
67 | (draggedItem.index + 1)..visibleItems[currentVisibleItemIndex].index
68 | } else {
69 | var currentItemVisibleIndex = 0
70 | while (
71 | currentItemVisibleIndex < visibleItems.lastIndex &&
72 | visibleItems[currentItemVisibleIndex].index < draggedItem.index &&
73 | visibleItems[currentItemVisibleIndex].offset < draggedOffsetTotal
74 | ) {
75 | currentItemVisibleIndex++
76 | }
77 | visibleItems[currentItemVisibleIndex].index until draggedItem.index
78 | }
79 | }
80 |
81 | fun onDragStart(draggedItemAbsoluteIndex: Int) {
82 | val draggedItemVisibleIndex = draggedItemAbsoluteIndex - listState.firstVisibleItemIndex
83 | draggedItemInfo = listState.layoutInfo.visibleItemsInfo[draggedItemVisibleIndex]
84 | draggedItemIndex = draggedItemAbsoluteIndex
85 | }
86 |
87 | fun onDrag(delta: Float) {
88 | draggedDistance += delta
89 | val draggedItemSize = draggedItemInfo?.size ?: return
90 | shiftedState = DragState.Shifted(
91 | if (draggedDistance > 0) -draggedItemSize else draggedItemSize
92 | )
93 | }
94 |
95 | fun onDragStop() {
96 | if (shiftedItemsIndices.isEmpty()) {
97 | draggedItemIndex = -1
98 | draggedItemInfo = null
99 | draggedDistance = 0f
100 | } else {
101 | val fromIndex = draggedItemInfo!!.index
102 | val toIndex =
103 | if (draggedDistance > 0) shiftedItemsIndices.last else shiftedItemsIndices.first
104 | val firstItemIndex = listState.firstVisibleItemIndex
105 | if (firstItemIndex == draggedItemIndex || firstItemIndex in shiftedItemsIndices) {
106 | val firstItemOffset = listState.firstVisibleItemScrollOffset
107 | onFirstVisibleItemChange(VisibleItemInfo(firstItemIndex, firstItemOffset))
108 | }
109 | onMove(fromIndex, toIndex)
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.gradle.api.tasks.testing.logging.TestLogEvent
2 | import java.util.Properties
3 | import java.io.FileInputStream
4 |
5 | plugins {
6 | alias(libs.plugins.android.application)
7 | alias(libs.plugins.kotlin)
8 | alias(libs.plugins.hilt)
9 | alias(libs.plugins.ksp)
10 | alias(libs.plugins.room)
11 | alias(libs.plugins.compose.compiler)
12 | id("kotlin-parcelize")
13 | id("kotlin-kapt")
14 | id("kotlinx-serialization")
15 | }
16 |
17 | kotlin {
18 | jvmToolchain(17)
19 | }
20 |
21 | val keystorePropertiesFile = rootProject.file("keystore.properties")
22 | val keystoreProperties = Properties()
23 | keystoreProperties.load(FileInputStream(keystorePropertiesFile))
24 |
25 | android {
26 | namespace = "io.github.soundremote"
27 | compileSdk = 35
28 | defaultConfig {
29 | applicationId = "io.github.soundremote"
30 | minSdk = 21
31 | targetSdk = 35
32 | versionCode = 12
33 | versionName = "0.5.0"
34 | testInstrumentationRunner = "io.github.soundremote.CustomTestRunner"
35 | }
36 | signingConfigs {
37 | create("release config") {
38 | keyAlias = keystoreProperties["keyAlias"] as String
39 | keyPassword = keystoreProperties["keyPassword"] as String
40 | storeFile = file(keystoreProperties["storeFile"] as String)
41 | storePassword = keystoreProperties["storePassword"] as String
42 | }
43 | }
44 | buildTypes {
45 | release {
46 | isMinifyEnabled = true
47 | isShrinkResources = true
48 | proguardFiles(
49 | getDefaultProguardFile("proguard-android-optimize.txt"),
50 | "proguard-rules.pro"
51 | )
52 | signingConfig = signingConfigs.getByName("release config")
53 | }
54 | }
55 | buildFeatures {
56 | compose = true
57 | buildConfig = true
58 | }
59 | ksp {
60 | arg("room.generateKotlin", "true")
61 | }
62 | buildToolsVersion = "35.0.0"
63 | sourceSets {
64 | getByName("androidTest").assets.srcDir("$projectDir/schemas")
65 | }
66 | lint {
67 | warning.add("MissingTranslation")
68 | }
69 | dependenciesInfo {
70 | includeInApk = false
71 | includeInBundle = false
72 | }
73 | }
74 |
75 | tasks.withType {
76 | useJUnitPlatform()
77 | testLogging {
78 | events(TestLogEvent.FAILED)
79 | }
80 | }
81 |
82 | room {
83 | schemaDirectory("$projectDir/schemas")
84 | }
85 |
86 | dependencies {
87 | implementation(libs.androidx.appcompat)
88 | implementation(libs.androidx.media)
89 | implementation(libs.androidx.core.ktx)
90 | implementation(libs.material)
91 | implementation(libs.androidx.activity.ktx) // For the predictive back gesture
92 | implementation(libs.bundles.androidx.lifeycle)
93 | // Compose
94 | val composeBom = platform(libs.androidx.compose.bom)
95 | implementation(composeBom)
96 | androidTestImplementation(composeBom)
97 | // UI
98 | implementation(libs.androidx.compose.material3)
99 | implementation(libs.androidx.compose.material3.adaptive)
100 | // Android Studio Preview support
101 | implementation(libs.androidx.compose.ui.tooling.preview)
102 | debugImplementation(libs.androidx.compose.ui.tooling)
103 | // UI Tests
104 | androidTestImplementation(libs.androidx.compose.ui.test.junit4)
105 | debugImplementation(libs.androidx.compose.ui.test.manifest)
106 | // Instrumented tests
107 | androidTestImplementation(libs.androidx.runner)
108 | androidTestImplementation(libs.androidx.test.ktx)
109 | androidTestImplementation(libs.androidx.navigation.testing)
110 | androidTestImplementation(libs.androidx.room.testing)
111 | // Local tests
112 | testImplementation(libs.bundles.local.tests)
113 | testRuntimeOnly(libs.junit.platform.launcher)
114 | // JOpus
115 | implementation(libs.jopus)
116 | // Room
117 | implementation(libs.androidx.room.ktx)
118 | ksp(libs.androidx.room.compiler)
119 | // Preference datastore
120 | implementation(libs.androidx.datastore.preferences)
121 | // Hilt
122 | implementation(libs.hilt.android)
123 | kapt(libs.hilt.compiler)
124 | implementation(libs.androidx.hilt.navigation.compose)
125 | androidTestImplementation(libs.hilt.android.testing)
126 | kaptAndroidTest(libs.hilt.compiler)
127 | // Navigation
128 | implementation(libs.androidx.navigation.compose)
129 | // Serialization
130 | implementation(libs.kotlinx.serialization.json)
131 | // Accompanist
132 | implementation(libs.accompanist.permissions)
133 | // Guava
134 | implementation(libs.guava)
135 | // Seismic
136 | implementation(libs.seismic)
137 | // Timber
138 | implementation(libs.timber)
139 | }
140 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/ui/hotkey/HotkeyViewModel.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui.hotkey
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import dagger.hilt.android.lifecycle.HiltViewModel
6 | import io.github.soundremote.data.Hotkey
7 | import io.github.soundremote.data.HotkeyRepository
8 | import io.github.soundremote.util.Key
9 | import io.github.soundremote.util.KeyCode
10 | import io.github.soundremote.util.KeyGroup
11 | import io.github.soundremote.util.ModKey
12 | import io.github.soundremote.util.Mods
13 | import io.github.soundremote.util.generateDescription
14 | import io.github.soundremote.util.isModActive
15 | import kotlinx.coroutines.flow.MutableStateFlow
16 | import kotlinx.coroutines.flow.StateFlow
17 | import kotlinx.coroutines.launch
18 | import javax.inject.Inject
19 |
20 | enum class HotkeyScreenMode { CREATE, EDIT }
21 |
22 | data class HotkeyScreenUIState(
23 | val mode: HotkeyScreenMode = HotkeyScreenMode.CREATE,
24 | val name: String = "",
25 | val win: Boolean = false,
26 | val ctrl: Boolean = false,
27 | val shift: Boolean = false,
28 | val alt: Boolean = false,
29 | val keyCode: KeyCode? = null,
30 | val keyGroupIndex: Int = KeyGroup.LETTER_DIGIT.index,
31 | )
32 |
33 | @HiltViewModel
34 | internal class HotkeyViewModel @Inject constructor(
35 | private val hotkeyRepository: HotkeyRepository
36 | ) : ViewModel() {
37 | private var initialHotkey: Hotkey? = null
38 |
39 | private val _hotkeyScreenState: MutableStateFlow =
40 | MutableStateFlow(HotkeyScreenUIState())
41 | val hotkeyScreenState: StateFlow
42 | get() = _hotkeyScreenState
43 |
44 | fun loadHotkey(id: Int) = viewModelScope.launch {
45 | hotkeyRepository.getById(id)?.let { hotkey ->
46 | initialHotkey = hotkey
47 | _hotkeyScreenState.value = HotkeyScreenUIState(
48 | mode = HotkeyScreenMode.EDIT,
49 | name = hotkey.name,
50 | win = hotkey.isModActive(ModKey.WIN),
51 | ctrl = hotkey.isModActive(ModKey.CTRL),
52 | shift = hotkey.isModActive(ModKey.SHIFT),
53 | alt = hotkey.isModActive(ModKey.ALT),
54 | keyCode = hotkey.keyCode,
55 | keyGroupIndex = getKeyGroupIndex(hotkey.keyCode),
56 | )
57 | }
58 | }
59 |
60 | private fun getKeyGroupIndex(keyCode: KeyCode?): Int {
61 | if (keyCode == null) return KeyGroup.LETTER_DIGIT.index
62 | val key = Key.entries.find { it.keyCode == keyCode }
63 | return key?.group?.index ?: KeyGroup.LETTER_DIGIT.index
64 | }
65 |
66 | fun updateKeyCode(keyCode: KeyCode?) {
67 | _hotkeyScreenState.value = _hotkeyScreenState.value
68 | .copy(keyCode = keyCode, keyGroupIndex = getKeyGroupIndex(keyCode))
69 | }
70 |
71 | fun updateName(name: String) {
72 | _hotkeyScreenState.value = _hotkeyScreenState.value.copy(name = name)
73 | }
74 |
75 | fun updateMod(mod: ModKey, value: Boolean) {
76 | _hotkeyScreenState.value = when (mod) {
77 | ModKey.WIN -> _hotkeyScreenState.value.copy(win = value)
78 | ModKey.CTRL -> _hotkeyScreenState.value.copy(ctrl = value)
79 | ModKey.SHIFT -> _hotkeyScreenState.value.copy(shift = value)
80 | ModKey.ALT -> _hotkeyScreenState.value.copy(alt = value)
81 | }
82 | }
83 |
84 | fun canSave(): Boolean {
85 | return hotkeyScreenState.value.keyCode != null
86 | }
87 |
88 | fun saveHotkey(keyLabel: String) {
89 | hotkeyScreenState.value.let { currentState ->
90 | val currentKeyCode = currentState.keyCode ?: return@let
91 | val mods = Mods(
92 | win = currentState.win,
93 | ctrl = currentState.ctrl,
94 | shift = currentState.shift,
95 | alt = currentState.alt,
96 | )
97 | val name: String = currentState.name.ifBlank {
98 | generateDescription(keyLabel, mods)
99 | }
100 |
101 | val hotkeyToUpdate = initialHotkey
102 | if (hotkeyToUpdate == null) {
103 | val hotkey = Hotkey(currentKeyCode, name, mods)
104 | viewModelScope.launch {
105 | hotkeyRepository.insert(hotkey)
106 | }
107 | } else {
108 | val hotkey = hotkeyToUpdate.copy(
109 | keyCode = currentKeyCode,
110 | mods = mods,
111 | name = name,
112 | )
113 | viewModelScope.launch {
114 | hotkeyRepository.update(hotkey)
115 | }
116 | }
117 | }
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/soundremote/audio/AudioPipe.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.audio
2 |
3 | import androidx.annotation.IntDef
4 | import io.github.soundremote.audio.decoder.OpusAudioDecoder
5 | import io.github.soundremote.audio.sink.PlaybackSink
6 | import io.github.soundremote.util.Audio.PACKET_CONCEAL_LIMIT
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.Job
10 | import kotlinx.coroutines.SupervisorJob
11 | import kotlinx.coroutines.cancelAndJoin
12 | import kotlinx.coroutines.channels.ReceiveChannel
13 | import kotlinx.coroutines.isActive
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.selects.select
16 | import java.nio.ByteBuffer
17 | import java.util.concurrent.atomic.AtomicInteger
18 |
19 | class AudioPipe(
20 | private val uncompressedAudio: ReceiveChannel,
21 | private val opusAudio: ReceiveChannel,
22 | private val packetsLost: AtomicInteger,
23 | ) {
24 | private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
25 | private val decoder = OpusAudioDecoder()
26 | private val playback = PlaybackSink()
27 |
28 | // An audio packet worth of silence
29 | private val silencePacket = ByteBuffer.allocate(decoder.bytesPerPacket)
30 |
31 | private var playJob: Job? = null
32 | private var stopJob: Job? = null
33 | private val playLock = Any()
34 | private val stopLock = Any()
35 |
36 | @Volatile
37 | @PipeState
38 | var state: Int = PIPE_STOPPED
39 | private set
40 |
41 | fun start() {
42 | if (state == PIPE_RELEASED) {
43 | throw IllegalStateException("Can't start(): AudioPipe is released")
44 | }
45 | synchronized(playLock) {
46 | if (playJob?.isActive == true) return
47 | playJob = scope.launch {
48 | stopJob?.join()
49 | state = PIPE_PLAYING
50 | playback.start()
51 | while (isActive) {
52 | select {
53 | uncompressedAudio.onReceive { audio ->
54 | concealLossesUncompressed()
55 |
56 | playback.play(audio)
57 | }
58 | opusAudio.onReceive { audio ->
59 | concealLossesOpus()
60 |
61 | val encoded = ByteArray(audio.remaining())
62 | audio.get(encoded)
63 | val decoded = ByteBuffer.allocate(decoder.bytesPerPacket)
64 | val decodedBytes = decoder.decode(encoded, decoded.array())
65 | decoded.limit(decodedBytes)
66 | playback.play(decoded)
67 | }
68 | }
69 | }
70 | }
71 | }
72 | }
73 |
74 | private fun packetsLost(): Int = packetsLost.getAndSet(0)
75 | .takeIf { it < PACKET_CONCEAL_LIMIT } ?: 0
76 |
77 | private fun concealLossesUncompressed() {
78 | val packetsToConceal = packetsLost()
79 | repeat(packetsToConceal) {
80 | playback.play(silencePacket.duplicate())
81 | }
82 | }
83 |
84 | private fun concealLossesOpus() {
85 | var packetsToConceal = packetsLost()
86 | while (packetsToConceal > 0) {
87 | val packets = packetsToConceal.coerceAtMost(decoder.maxPacketsPerPlc)
88 | val decodedData = ByteBuffer.allocate(decoder.bytesPerPacket * packets)
89 | val decodedBytes = decoder.plc(decodedData.array(), decoder.framesPerPacket * packets)
90 | decodedData.limit(decodedBytes)
91 | playback.play(decodedData)
92 | packetsToConceal -= packets
93 | }
94 | }
95 |
96 | fun stop() {
97 | if (state == PIPE_RELEASED) {
98 | throw IllegalStateException("Can't stop(): AudioPipe is released")
99 | }
100 | synchronized(stopLock) {
101 | if (stopJob?.isActive == true) return
102 | stopJob = scope.launch {
103 | playJob?.cancelAndJoin()
104 | state = PIPE_STOPPED
105 | playback.stop()
106 | }
107 | }
108 | }
109 |
110 | fun release() {
111 | state = PIPE_RELEASED
112 | scope.launch {
113 | playJob?.cancelAndJoin()
114 | playJob = null
115 | decoder.release()
116 | playback.release()
117 | }
118 | }
119 |
120 | companion object {
121 | @Retention(AnnotationRetention.SOURCE)
122 | @IntDef(PIPE_PLAYING, PIPE_STOPPED, PIPE_RELEASED)
123 | annotation class PipeState
124 |
125 | internal const val PIPE_PLAYING = 1
126 | internal const val PIPE_STOPPED = 2
127 | internal const val PIPE_RELEASED = 3
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/ui/HotkeyListViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import io.github.soundremote.MainDispatcherExtension
4 | import io.github.soundremote.data.Hotkey
5 | import io.github.soundremote.data.TestHotkeyRepository
6 | import io.github.soundremote.getHotkey
7 | import io.github.soundremote.ui.hotkeylist.HotkeyListViewModel
8 | import kotlinx.coroutines.ExperimentalCoroutinesApi
9 | import kotlinx.coroutines.launch
10 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
11 | import kotlinx.coroutines.test.runTest
12 | import org.junit.jupiter.api.Assertions.assertFalse
13 | import org.junit.jupiter.api.Assertions.assertIterableEquals
14 | import org.junit.jupiter.api.Assertions.assertNull
15 | import org.junit.jupiter.api.BeforeEach
16 | import org.junit.jupiter.api.DisplayName
17 | import org.junit.jupiter.api.Test
18 | import org.junit.jupiter.api.extension.ExtendWith
19 | import org.junit.jupiter.params.ParameterizedTest
20 | import org.junit.jupiter.params.provider.Arguments
21 | import org.junit.jupiter.params.provider.MethodSource
22 | import java.util.stream.Stream
23 |
24 | @OptIn(ExperimentalCoroutinesApi::class)
25 | @ExtendWith(MainDispatcherExtension::class)
26 | @DisplayName("HotkeyListViewModel")
27 | class HotkeyListViewModelTest {
28 | private var hotkeyRepository = TestHotkeyRepository()
29 |
30 | private lateinit var viewModel: HotkeyListViewModel
31 |
32 | @BeforeEach
33 | fun setup() {
34 | viewModel = HotkeyListViewModel(hotkeyRepository)
35 | }
36 |
37 | @Test
38 | @DisplayName("deleteHotkey() deletes")
39 | fun deleteHotkey_deletes() = runTest {
40 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
41 | viewModel.hotkeyListState.collect {}
42 | }
43 |
44 | val id = 10
45 | val hotkeys = listOf(
46 | getHotkey(id = id),
47 | getHotkey(id = id + 1),
48 | )
49 | hotkeyRepository.setHotkeys(hotkeys)
50 |
51 | viewModel.deleteHotkey(id)
52 |
53 | val actual = viewModel.hotkeyListState.value.hotkeys.find { it.id == id }
54 | assertNull(actual)
55 |
56 | collectJob.cancel()
57 | }
58 |
59 | @Test
60 | @DisplayName("changeFavoured() changes favoured status")
61 | fun changeFavoured_changes() = runTest {
62 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
63 | viewModel.hotkeyListState.collect {}
64 | }
65 |
66 | val id = 10
67 | val hotkeys = listOf(getHotkey(id = id, favoured = true))
68 | hotkeyRepository.setHotkeys(hotkeys)
69 |
70 | viewModel.changeFavoured(id, false)
71 |
72 | val actual = viewModel.hotkeyListState.value.hotkeys.find { it.id == id }!!.favoured
73 | assertFalse(actual)
74 |
75 | collectJob.cancel()
76 | }
77 |
78 | @ParameterizedTest(name = "from {1} to {2} results in {3}")
79 | @MethodSource("io.github.soundremote.ui.HotkeyListViewModelTest#moveHotkeyProvider")
80 | @DisplayName("moveHotkey() moves correctly")
81 | fun moveHotkey_movesCorrectly(
82 | hotkeys: List,
83 | from: Int,
84 | to: Int,
85 | expected: List
86 | ) = runTest {
87 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
88 | viewModel.hotkeyListState.collect {}
89 | }
90 |
91 | hotkeyRepository.setHotkeys(hotkeys)
92 |
93 | viewModel.moveHotkey(from, to)
94 |
95 | val actual = viewModel.hotkeyListState.value.hotkeys.map { it.id }
96 | assertIterableEquals(expected, actual)
97 |
98 | collectJob.cancel()
99 | }
100 |
101 | companion object {
102 | /**
103 | * List<[Hotkey]>, from: Int, to: Int, expectedOrderedIds: List``
104 | */
105 | @JvmStatic
106 | fun moveHotkeyProvider(): Stream = Stream.of(
107 | Arguments.arguments(
108 | generateZeroOrderHotkeys(10),
109 | 3,
110 | 8,
111 | listOf(1, 2, 3, 5, 6, 7, 8, 9, 4, 10),
112 | ),
113 | Arguments.arguments(
114 | generateZeroOrderHotkeys(10),
115 | 9,
116 | 0,
117 | listOf(10, 1, 2, 3, 4, 5, 6, 7, 8, 9)
118 | ),
119 | Arguments.arguments(
120 | generateZeroOrderHotkeys(8),
121 | 5,
122 | 5,
123 | listOf(1, 2, 3, 4, 5, 6, 7, 8)
124 | ),
125 | )
126 |
127 | /**
128 | * Generates hotkeys with order value of 0
129 | */
130 | private fun generateZeroOrderHotkeys(n: Int): List = buildList {
131 | repeat(n) {
132 | val id = it + 1
133 | add(getHotkey(id = id, order = 0))
134 | }
135 | }
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/app/src/test/java/io/github/soundremote/ui/EventsViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.soundremote.ui
2 |
3 | import io.github.soundremote.MainDispatcherExtension
4 | import io.github.soundremote.data.Action
5 | import io.github.soundremote.data.ActionData
6 | import io.github.soundremote.data.ActionType
7 | import io.github.soundremote.data.Event
8 | import io.github.soundremote.data.EventAction
9 | import io.github.soundremote.data.TestEventActionRepository
10 | import io.github.soundremote.data.TestHotkeyRepository
11 | import io.github.soundremote.getHotkey
12 | import io.github.soundremote.ui.events.EventsViewModel
13 | import kotlinx.coroutines.ExperimentalCoroutinesApi
14 | import kotlinx.coroutines.launch
15 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
16 | import kotlinx.coroutines.test.runTest
17 | import org.junit.jupiter.api.Assertions.assertEquals
18 | import org.junit.jupiter.api.Assertions.assertNull
19 | import org.junit.jupiter.api.Assertions.assertTrue
20 | import org.junit.jupiter.api.BeforeEach
21 | import org.junit.jupiter.api.DisplayName
22 | import org.junit.jupiter.api.Nested
23 | import org.junit.jupiter.api.Test
24 | import org.junit.jupiter.api.extension.ExtendWith
25 |
26 | @OptIn(ExperimentalCoroutinesApi::class)
27 | @ExtendWith(MainDispatcherExtension::class)
28 | @DisplayName("EventsViewModel")
29 | internal class EventsViewModelTest {
30 | private var hotkeyRepository = TestHotkeyRepository()
31 | private var eventActionRepository = TestEventActionRepository()
32 | private lateinit var viewModel: EventsViewModel
33 |
34 | @BeforeEach
35 | fun setup() {
36 | viewModel = EventsViewModel(eventActionRepository, hotkeyRepository)
37 | }
38 |
39 | @DisplayName("setHotkeyForEvent")
40 | @Nested
41 | inner class SetHotkeyForEventTests {
42 | @Test
43 | @DisplayName("sets action for an event without action")
44 | fun eventWithoutAction_existingAction_setsAction() = runTest {
45 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
46 | viewModel.uiState.collect {}
47 | }
48 |
49 | val expectedId = 10
50 | val hotkeys = listOf(getHotkey(id = expectedId))
51 | hotkeyRepository.setHotkeys(hotkeys)
52 | val eventId = Event.CALL_END.id
53 | assertNull(viewModel.uiState.value.events.find { it.id == eventId }?.action)
54 |
55 | viewModel.setActionForEvent(eventId, Action(ActionType.HOTKEY, expectedId))
56 |
57 | val actual = viewModel.uiState.value.events.find { it.id == eventId }?.action?.id
58 | assertEquals(expectedId, actual)
59 |
60 | collectJob.cancel()
61 | }
62 |
63 | @Test
64 | @DisplayName("removes action from an event with action")
65 | fun eventWithAction_nullAction_removesAction() = runTest {
66 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
67 | viewModel.uiState.collect {}
68 | }
69 |
70 | val expectedId = 1
71 | val hotkeys = listOf(getHotkey(id = expectedId))
72 | hotkeyRepository.setHotkeys(hotkeys)
73 | val eventId = Event.CALL_BEGIN.id
74 | val eventActions =
75 | listOf(EventAction(eventId, ActionData(ActionType.HOTKEY, expectedId)))
76 | eventActionRepository.setEventActions(eventActions)
77 | assertTrue(viewModel.uiState.value.events.find { it.id == eventId }?.action?.id == expectedId)
78 |
79 | viewModel.setActionForEvent(eventId, null)
80 |
81 | val actual = viewModel.uiState.value.events.find { it.id == eventId }?.action
82 | assertNull(actual)
83 |
84 | collectJob.cancel()
85 | }
86 |
87 | @Test
88 | @DisplayName("updates action of an event with another action")
89 | fun eventWithAction_existingAction_updatesAction() = runTest {
90 | val collectJob = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
91 | viewModel.uiState.collect {}
92 | }
93 |
94 | val oldHotkeyId = 1
95 | val newHotkeyId = 2
96 | val hotkeys = listOf(
97 | getHotkey(id = oldHotkeyId),
98 | getHotkey(id = newHotkeyId),
99 | )
100 | hotkeyRepository.setHotkeys(hotkeys)
101 | val eventId = Event.CALL_BEGIN.id
102 | val eventActions =
103 | listOf(EventAction(eventId, ActionData(ActionType.HOTKEY, oldHotkeyId)))
104 | eventActionRepository.setEventActions(eventActions)
105 | assertEquals(
106 | oldHotkeyId,
107 | viewModel.uiState.value.events.find { it.id == eventId }?.action?.id
108 | )
109 |
110 | viewModel.setActionForEvent(eventId, Action(ActionType.HOTKEY, newHotkeyId))
111 |
112 | val actual = viewModel.uiState.value.events.find { it.id == eventId }?.action?.id
113 | assertEquals(newHotkeyId, actual)
114 |
115 | collectJob.cancel()
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------