├── 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 | [Get it on F-Droid](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 | Home screen⠀ 18 | Events screen⠀ 19 | Notification 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 | --------------------------------------------------------------------------------