From 1f2bc3d255b10c3e4b30804b00059fe3b4cb97f4 Mon Sep 17 00:00:00 2001 From: New Author Name Date: Sun, 6 Oct 2024 12:01:21 +0530 Subject: [PATCH] dolby: Switch to XiaomiDolby Based on hardware/xiaomi History - - Enable TARGET_USES_DOLBY - Add intelligent equalizer setting - Remove deprecated PlainTooltipBox - Introduce graphical equalizer - Add launcher icon - fixup! Restore all settings upon bootup - Override AudioFx - Use all shared resources from devicesettings - Fix build with kotlinc 1.9.0 - Restore current profile _after_ resetting profiles - Do not set volume leveler amount - Restore all settings upon bootup - Rewrite in Kotlin - Revert "Re-enable speaker virtualization after bootup" - Convert to SwitchPreferenceCompat - Migrate to CompoundButton.OnCheckedChangeListener - Enable use_resource_processor for all sysui deps - Introduce Dolby Atmos Co-authored-by: Henrique Silva Co-authored-by: Pranav Vashi Co-authored-by: Fabian Leutenegger Co-authored-by: basamaryan --- XiaomiDolby/Android.bp | 28 ++ XiaomiDolby/AndroidManifest.xml | 79 +++++ XiaomiDolby/res/drawable/ic_dolby.xml | 8 + XiaomiDolby/res/drawable/ic_dolby_qs.xml | 8 + XiaomiDolby/res/drawable/ic_ieq_balanced.xml | 24 ++ XiaomiDolby/res/drawable/ic_ieq_detailed.xml | 24 ++ XiaomiDolby/res/drawable/ic_ieq_off.xml | 13 + XiaomiDolby/res/drawable/ic_ieq_warm.xml | 28 ++ .../res/drawable/ic_launcher_background.xml | 12 + .../drawable/ic_launcher_background__0.xml | 6 + .../drawable/ic_launcher_background__1.xml | 6 + .../res/drawable/ic_launcher_foreground.xml | 8 + XiaomiDolby/res/drawable/ic_launcher_mono.xml | 4 + .../res/drawable/reset_settings_24px.xml | 9 + XiaomiDolby/res/drawable/save_as_24px.xml | 9 + XiaomiDolby/res/layout/ieq_icon_layout.xml | 41 +++ XiaomiDolby/res/mipmap-anydpi/ic_launcher.xml | 7 + XiaomiDolby/res/values/arrays.xml | 102 ++++++ XiaomiDolby/res/values/strings.xml | 70 ++++ XiaomiDolby/res/xml/dolby_settings.xml | 77 +++++ .../dolby/xiaomi/BootCompletedReceiver.kt | 27 ++ .../co/aospa/dolby/xiaomi/DolbyActivity.kt | 23 ++ .../co/aospa/dolby/xiaomi/DolbyAudioEffect.kt | 136 ++++++++ .../co/aospa/dolby/xiaomi/DolbyConstants.kt | 60 ++++ .../co/aospa/dolby/xiaomi/DolbyController.kt | 316 ++++++++++++++++++ .../co/aospa/dolby/xiaomi/DolbyTileService.kt | 36 ++ .../co/aospa/dolby/xiaomi/SummaryProvider.kt | 70 ++++ .../dolby/xiaomi/geq/EqualizerActivity.kt | 53 +++ .../aospa/dolby/xiaomi/geq/data/BandGain.kt | 12 + .../xiaomi/geq/data/EqualizerRepository.kt | 183 ++++++++++ .../co/aospa/dolby/xiaomi/geq/data/Preset.kt | 14 + .../dolby/xiaomi/geq/ui/BandGainSlider.kt | 101 ++++++ .../xiaomi/geq/ui/BandGainSliderLabels.kt | 71 ++++ .../dolby/xiaomi/geq/ui/ConfirmationDialog.kt | 58 ++++ .../dolby/xiaomi/geq/ui/EqualizerBands.kt | 39 +++ .../dolby/xiaomi/geq/ui/EqualizerScreen.kt | 41 +++ .../dolby/xiaomi/geq/ui/EqualizerViewModel.kt | 175 ++++++++++ .../dolby/xiaomi/geq/ui/PresetNameDialog.kt | 94 ++++++ .../geq/ui/PresetNameValidationError.kt | 25 ++ .../dolby/xiaomi/geq/ui/PresetSelector.kt | 176 ++++++++++ .../dolby/xiaomi/geq/ui/TooltipIconButton.kt | 46 +++ .../xiaomi/preference/DolbyIeqPreference.kt | 45 +++ .../xiaomi/preference/DolbyPreferenceStore.kt | 64 ++++ .../preference/DolbySettingsFragment.kt | 299 +++++++++++++++++ dolby.mk | 7 +- 45 files changed, 2731 insertions(+), 3 deletions(-) create mode 100644 XiaomiDolby/Android.bp create mode 100644 XiaomiDolby/AndroidManifest.xml create mode 100644 XiaomiDolby/res/drawable/ic_dolby.xml create mode 100644 XiaomiDolby/res/drawable/ic_dolby_qs.xml create mode 100644 XiaomiDolby/res/drawable/ic_ieq_balanced.xml create mode 100644 XiaomiDolby/res/drawable/ic_ieq_detailed.xml create mode 100644 XiaomiDolby/res/drawable/ic_ieq_off.xml create mode 100644 XiaomiDolby/res/drawable/ic_ieq_warm.xml create mode 100644 XiaomiDolby/res/drawable/ic_launcher_background.xml create mode 100644 XiaomiDolby/res/drawable/ic_launcher_background__0.xml create mode 100644 XiaomiDolby/res/drawable/ic_launcher_background__1.xml create mode 100644 XiaomiDolby/res/drawable/ic_launcher_foreground.xml create mode 100644 XiaomiDolby/res/drawable/ic_launcher_mono.xml create mode 100644 XiaomiDolby/res/drawable/reset_settings_24px.xml create mode 100644 XiaomiDolby/res/drawable/save_as_24px.xml create mode 100644 XiaomiDolby/res/layout/ieq_icon_layout.xml create mode 100644 XiaomiDolby/res/mipmap-anydpi/ic_launcher.xml create mode 100644 XiaomiDolby/res/values/arrays.xml create mode 100644 XiaomiDolby/res/values/strings.xml create mode 100644 XiaomiDolby/res/xml/dolby_settings.xml create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/BootCompletedReceiver.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyActivity.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyAudioEffect.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyConstants.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyController.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyTileService.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/SummaryProvider.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/EqualizerActivity.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/BandGain.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/EqualizerRepository.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/Preset.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSlider.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSliderLabels.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/ConfirmationDialog.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerBands.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerScreen.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerViewModel.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameDialog.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameValidationError.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetSelector.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/TooltipIconButton.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyIeqPreference.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyPreferenceStore.kt create mode 100644 XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbySettingsFragment.kt diff --git a/XiaomiDolby/Android.bp b/XiaomiDolby/Android.bp new file mode 100644 index 0000000..0e0a36e --- /dev/null +++ b/XiaomiDolby/Android.bp @@ -0,0 +1,28 @@ +// +// Copyright (C) 2017-2021 The LineageOS Project +// (C) 2023-24 Paranoid Android +// +// SPDX-License-Identifier: Apache-2.0 +// + +android_app { + name: "XiaomiDolby", + + srcs: ["src/**/*.kt"], + resource_dirs: ["res"], + certificate: "platform", + platform_apis: true, + system_ext_specific: true, + privileged: true, + + overrides: ["MusicFX", "AudioFX"], + static_libs: [ + "SettingsLib", + "SpaLib", + "androidx.activity_activity-compose", + "androidx.compose.material3_material3", + "androidx.compose.runtime_runtime", + "androidx.preference_preference", + "org.lineageos.settings.resources", + ], +} diff --git a/XiaomiDolby/AndroidManifest.xml b/XiaomiDolby/AndroidManifest.xml new file mode 100644 index 0000000..b088f4f --- /dev/null +++ b/XiaomiDolby/AndroidManifest.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_dolby.xml b/XiaomiDolby/res/drawable/ic_dolby.xml new file mode 100644 index 0000000..4cf0aac --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_dolby.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_dolby_qs.xml b/XiaomiDolby/res/drawable/ic_dolby_qs.xml new file mode 100644 index 0000000..5ed3f94 --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_dolby_qs.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_ieq_balanced.xml b/XiaomiDolby/res/drawable/ic_ieq_balanced.xml new file mode 100644 index 0000000..961a73a --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_ieq_balanced.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_ieq_detailed.xml b/XiaomiDolby/res/drawable/ic_ieq_detailed.xml new file mode 100644 index 0000000..375b5c6 --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_ieq_detailed.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_ieq_off.xml b/XiaomiDolby/res/drawable/ic_ieq_off.xml new file mode 100644 index 0000000..ce609cb --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_ieq_off.xml @@ -0,0 +1,13 @@ + + + diff --git a/XiaomiDolby/res/drawable/ic_ieq_warm.xml b/XiaomiDolby/res/drawable/ic_ieq_warm.xml new file mode 100644 index 0000000..6a12d2a --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_ieq_warm.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_launcher_background.xml b/XiaomiDolby/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..e4fa04b --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_launcher_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/XiaomiDolby/res/drawable/ic_launcher_background__0.xml b/XiaomiDolby/res/drawable/ic_launcher_background__0.xml new file mode 100644 index 0000000..ed4f520 --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_launcher_background__0.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/XiaomiDolby/res/drawable/ic_launcher_background__1.xml b/XiaomiDolby/res/drawable/ic_launcher_background__1.xml new file mode 100644 index 0000000..a1601b9 --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_launcher_background__1.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/XiaomiDolby/res/drawable/ic_launcher_foreground.xml b/XiaomiDolby/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..275407e --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/XiaomiDolby/res/drawable/ic_launcher_mono.xml b/XiaomiDolby/res/drawable/ic_launcher_mono.xml new file mode 100644 index 0000000..4fafb11 --- /dev/null +++ b/XiaomiDolby/res/drawable/ic_launcher_mono.xml @@ -0,0 +1,4 @@ + + + + diff --git a/XiaomiDolby/res/drawable/reset_settings_24px.xml b/XiaomiDolby/res/drawable/reset_settings_24px.xml new file mode 100644 index 0000000..3ba1f39 --- /dev/null +++ b/XiaomiDolby/res/drawable/reset_settings_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/XiaomiDolby/res/drawable/save_as_24px.xml b/XiaomiDolby/res/drawable/save_as_24px.xml new file mode 100644 index 0000000..c4dacfc --- /dev/null +++ b/XiaomiDolby/res/drawable/save_as_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/XiaomiDolby/res/layout/ieq_icon_layout.xml b/XiaomiDolby/res/layout/ieq_icon_layout.xml new file mode 100644 index 0000000..f04165c --- /dev/null +++ b/XiaomiDolby/res/layout/ieq_icon_layout.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/XiaomiDolby/res/mipmap-anydpi/ic_launcher.xml b/XiaomiDolby/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..baa7906 --- /dev/null +++ b/XiaomiDolby/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/XiaomiDolby/res/values/arrays.xml b/XiaomiDolby/res/values/arrays.xml new file mode 100644 index 0000000..bc952a2 --- /dev/null +++ b/XiaomiDolby/res/values/arrays.xml @@ -0,0 +1,102 @@ + + + + + + + @string/dolby_profile_dynamic + @string/dolby_profile_video + @string/dolby_profile_music + @string/dolby_profile_voice + + + + 0 + 1 + 2 + 8 + + + + @string/dolby_preset_default + @string/dolby_preset_rock + @string/dolby_preset_jazz + @string/dolby_preset_pop + @string/dolby_preset_classical + @string/dolby_preset_hiphop + @string/dolby_preset_blues + @string/dolby_preset_electronic + @string/dolby_preset_metal + + + + + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + 60,36,12,-12,-36,-24,-12,-8,-4,-20,-36,-20,-4,-20,-36,-16,4,32,60,60 + 8,8,8,8,8,0,-8,-8,-8,-24,-40,-20,0,4,8,8,8,8,8,8 + -13,-1,11,-25,-61,-29,3,11,19,19,19,15,11,-9,-29,-9,11,15,19,19 + -32,-32,-32,-32,-32,-32,-32,-28,-24,-4,16,0,-16,24,64,32,0,32,64,64 + 52,28,4,-20,-44,-24,-4,-4,-4,-24,-44,-24,-4,0,4,4,4,20,36,36 + 28,28,28,-36,-100,-68,-36,4,44,28,12,4,-4,4,12,4,-4,12,28,28 + 50,34,18,2,-14,-6,2,-2,-6,-26,-46,-26,-6,-2,2,2,2,2,2,2 + 40,24,8,8,8,-4,-16,-12,-8,-32,-56,-24,8,8,8,8,8,8,8,8 + + + + @string/dolby_off + @string/dolby_low + @string/dolby_medium + @string/dolby_high + @string/dolby_max + + + + 0 + 2 + 6 + 9 + 12 + + + + @string/dolby_low + @string/dolby_medium + @string/dolby_high + @string/dolby_max + + + + 4 + 24 + 44 + 64 + + + + @string/dolby_off + @string/dolby_balanced + @string/dolby_warm + @string/dolby_detailed + + + + 0 + 1 + 2 + 3 + + + diff --git a/XiaomiDolby/res/values/strings.xml b/XiaomiDolby/res/values/strings.xml new file mode 100644 index 0000000..f82c491 --- /dev/null +++ b/XiaomiDolby/res/values/strings.xml @@ -0,0 +1,70 @@ + + + + + + Dolby Atmos + Use Dolby Atmos + Choose a profile + Graphic equalizer + Off + On + Low + Medium + High + Max + Unknown + On (%1$s) + Settings + Bass enhancer + Dialogue enhancer + Speaker virtualization + Headphone virtualization + Stereo widening + Volume leveler + Connect headphones + Reset to defaults + Succesfully reset settings for %1$s profile + + + Dynamic + Movie/Video + Music + Voice + + + Flat (off) + Rock + Jazz + Pop + Classical + Hip Hop + Blues + Electronic + Country + Dance + Metal + + + Gain + Preset + Preset name + New preset + Rename preset + Delete preset + Do you want to delete this preset? + Reset gains + Do you want to reset this preset to defaults? + Preset name already exists! + Preset name is too long! + + + Intelligent equalizer + Balanced + Warm + Detailed + + diff --git a/XiaomiDolby/res/xml/dolby_settings.xml b/XiaomiDolby/res/xml/dolby_settings.xml new file mode 100644 index 0000000..29a458d --- /dev/null +++ b/XiaomiDolby/res/xml/dolby_settings.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/BootCompletedReceiver.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/BootCompletedReceiver.kt new file mode 100644 index 0000000..84c12fe --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/BootCompletedReceiver.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +private const val TAG = "XiaomiDolby-Boot" + +class BootCompletedReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + Log.d(TAG, "Received intent: ${intent.action}") + if (intent.action != Intent.ACTION_BOOT_COMPLETED) { + return + } + + Log.i(TAG, "Boot completed, starting dolby") + DolbyController.getInstance(context).onBootCompleted() + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyActivity.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyActivity.kt new file mode 100644 index 0000000..2a96d2b --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyActivity.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.os.Bundle +import co.aospa.dolby.xiaomi.preference.DolbySettingsFragment +import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity + +private const val TAG = "DolbyActivity" + +class DolbyActivity : CollapsingToolbarBaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + fragmentManager.beginTransaction() + .replace(com.android.settingslib.collapsingtoolbar.R.id.content_frame, DolbySettingsFragment(), TAG) + .commit() + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyAudioEffect.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyAudioEffect.kt new file mode 100644 index 0000000..71f0b27 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyAudioEffect.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.media.audiofx.AudioEffect +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog +import co.aospa.dolby.xiaomi.DolbyConstants.DsParam +import java.util.UUID + +class DolbyAudioEffect(priority: Int, audioSession: Int) : AudioEffect( + EFFECT_TYPE_NULL, EFFECT_TYPE_DAP, priority, audioSession +) { + + var dsOn: Boolean + get() = getIntParam(EFFECT_PARAM_ENABLE) == 1 + set(value) { + setIntParam(EFFECT_PARAM_ENABLE, if (value) 1 else 0) + enabled = value + } + + var profile: Int + get() = getIntParam(EFFECT_PARAM_PROFILE) + set(value) { + setIntParam(EFFECT_PARAM_PROFILE, value) + } + + private fun setIntParam(param: Int, value: Int) { + dlog(TAG, "setIntParam($param, $value)") + val buf = ByteArray(12) + int32ToByteArray(param, buf, 0) + int32ToByteArray(1, buf, 4) + int32ToByteArray(value, buf, 8) + checkStatus(setParameter(EFFECT_PARAM_CPDP_VALUES, buf)) + } + + private fun getIntParam(param: Int): Int { + val buf = ByteArray(12) + int32ToByteArray(param, buf, 0) + checkStatus(getParameter(EFFECT_PARAM_CPDP_VALUES + param, buf)) + return byteArrayToInt32(buf).also { + dlog(TAG, "getIntParam($param): $it") + } + } + + fun resetProfileSpecificSettings(profile: Int = this.profile) { + dlog(TAG, "resetProfileSpecificSettings: profile=$profile") + setIntParam(EFFECT_PARAM_RESET_PROFILE_SETTINGS, profile) + } + + fun setDapParameter(param: DsParam, values: IntArray, profile: Int = this.profile) { + dlog(TAG, "setDapParameter: profile=$profile param=$param") + val length = values.size + val buf = ByteArray((length + 4) * 4) + int32ToByteArray(EFFECT_PARAM_SET_PROFILE_PARAMETER, buf, 0) + int32ToByteArray(length + 1, buf, 4) + int32ToByteArray(profile, buf, 8) + int32ToByteArray(param.id, buf, 12) + int32ArrayToByteArray(values, buf, 16) + checkStatus(setParameter(EFFECT_PARAM_CPDP_VALUES, buf)) + } + + fun setDapParameter(param: DsParam, enable: Boolean, profile: Int = this.profile) = + setDapParameter(param, intArrayOf(if (enable) 1 else 0), profile) + + fun setDapParameter(param: DsParam, value: Int, profile: Int = this.profile) = + setDapParameter(param, intArrayOf(value), profile) + + fun getDapParameter(param: DsParam, profile: Int = this.profile): IntArray { + dlog(TAG, "getDapParameter: profile=$profile param=$param") + val length = param.length + val buf = ByteArray((length + 2) * 4) + val p = (param.id shl 16) + (profile shl 8) + EFFECT_PARAM_GET_PROFILE_PARAMETER + checkStatus(getParameter(p, buf)) + return byteArrayToInt32Array(buf, length) + } + + fun getDapParameterBool(param: DsParam, profile: Int = this.profile): Boolean = + getDapParameter(param, profile)[0] == 1 + + fun getDapParameterInt(param: DsParam, profile: Int = this.profile): Int = + getDapParameter(param, profile)[0] + + companion object { + private const val TAG = "DolbyAudioEffect" + private val EFFECT_TYPE_DAP = + UUID.fromString("9d4921da-8225-4f29-aefa-39537a04bcaa") + + private const val EFFECT_PARAM_ENABLE = 0 + private const val EFFECT_PARAM_CPDP_VALUES = 5 + private const val EFFECT_PARAM_PROFILE = 0xA000000 + private const val EFFECT_PARAM_SET_PROFILE_PARAMETER = 0x1000000 + private const val EFFECT_PARAM_GET_PROFILE_PARAMETER = 0x1000005 + private const val EFFECT_PARAM_RESET_PROFILE_SETTINGS = 0xC000000 + + private fun int32ToByteArray(value: Int, dst: ByteArray, index: Int) { + var idx = index + dst[idx++] = (value and 0xff).toByte() + dst[idx++] = ((value ushr 8) and 0xff).toByte() + dst[idx++] = ((value ushr 16) and 0xff).toByte() + dst[idx] = ((value ushr 24) and 0xff).toByte() + } + + private fun byteArrayToInt32(ba: ByteArray): Int { + return ((ba[3].toInt() and 0xff) shl 24) or + ((ba[2].toInt() and 0xff) shl 16) or + ((ba[1].toInt() and 0xff) shl 8) or + (ba[0].toInt() and 0xff) + } + + private fun int32ArrayToByteArray(src: IntArray, dst: ByteArray, index: Int) { + var idx = index + for (x in src) { + dst[idx++] = (x and 0xff).toByte() + dst[idx++] = ((x ushr 8) and 0xff).toByte() + dst[idx++] = ((x ushr 16) and 0xff).toByte() + dst[idx++] = ((x ushr 24) and 0xff).toByte() + } + } + + private fun byteArrayToInt32Array(ba: ByteArray, dstLength: Int): IntArray { + val srcLength = ba.size shr 2 + val dst = IntArray(dstLength.coerceAtMost(srcLength)) + for (i in dst.indices) { + dst[i] = ((ba[i * 4 + 3].toInt() and 0xff) shl 24) or + ((ba[i * 4 + 2].toInt() and 0xff) shl 16) or + ((ba[i * 4 + 1].toInt() and 0xff) shl 8) or + (ba[i * 4].toInt() and 0xff) + } + return dst + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyConstants.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyConstants.kt new file mode 100644 index 0000000..a434ce4 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyConstants.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.util.Log + +class DolbyConstants { + + enum class DsParam(val id: Int, val length: Int = 1) { + HEADPHONE_VIRTUALIZER(101), + SPEAKER_VIRTUALIZER(102), + VOLUME_LEVELER_ENABLE(103), + IEQ_PRESET(104), + DIALOGUE_ENHANCER_ENABLE(105), + DIALOGUE_ENHANCER_AMOUNT(108), + GEQ_BAND_GAINS(110, 20), + BASS_ENHANCER_ENABLE(111), + STEREO_WIDENING_AMOUNT(113); + + override fun toString(): String { + return "${name}(${id})" + } + } + + companion object { + const val TAG = "XiaomiDolby" + const val PREF_ENABLE = "dolby_enable" + const val PREF_PROFILE = "dolby_profile" + const val PREF_PRESET = "dolby_preset" + const val PREF_IEQ = "dolby_ieq" + const val PREF_HP_VIRTUALIZER = "dolby_virtualizer" + const val PREF_SPK_VIRTUALIZER = "dolby_spk_virtualizer" + const val PREF_STEREO = "dolby_stereo" + const val PREF_DIALOGUE = "dolby_dialogue" + const val PREF_BASS = "dolby_bass" + const val PREF_VOLUME = "dolby_volume" + const val PREF_RESET = "dolby_reset" + + val PROFILE_SPECIFIC_PREFS = setOf( + PREF_PRESET, + PREF_IEQ, + PREF_HP_VIRTUALIZER, + PREF_SPK_VIRTUALIZER, + PREF_STEREO, + PREF_DIALOGUE, + PREF_BASS, + PREF_VOLUME + ) + + fun dlog(tag: String, msg: String) { + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(tag, msg) + } + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyController.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyController.kt new file mode 100644 index 0000000..85cd829 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyController.kt @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.AudioManager.AudioPlaybackCallback +import android.media.AudioPlaybackConfiguration +import android.os.Handler +import android.util.Log +import androidx.preference.PreferenceManager +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog +import co.aospa.dolby.xiaomi.DolbyConstants.DsParam +import co.aospa.dolby.xiaomi.R + +internal class DolbyController private constructor( + private val context: Context +) { + private var dolbyEffect = DolbyAudioEffect(EFFECT_PRIORITY, audioSession = 0) + private val audioManager = context.getSystemService(AudioManager::class.java) + private val handler = Handler(context.mainLooper) + + // Restore current profile on every media session + private val playbackCallback = object : AudioPlaybackCallback() { + override fun onPlaybackConfigChanged(configs: List) { + val isPlaying = configs.any { + it.playerState == AudioPlaybackConfiguration.PLAYER_STATE_STARTED + } + dlog(TAG, "onPlaybackConfigChanged: isPlaying=$isPlaying") + if (isPlaying) + setCurrentProfile() + } + } + + // Restore current profile on audio device change + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + dlog(TAG, "onAudioDevicesAdded") + setCurrentProfile() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + dlog(TAG, "onAudioDevicesRemoved") + setCurrentProfile() + } + } + + private var registerCallbacks = false + set(value) { + if (field == value) return + field = value + dlog(TAG, "setRegisterCallbacks($value)") + if (value) { + audioManager!!.registerAudioPlaybackCallback(playbackCallback, handler) + audioManager.registerAudioDeviceCallback(audioDeviceCallback, handler) + } else { + audioManager!!.unregisterAudioPlaybackCallback(playbackCallback) + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + } + } + + var dsOn: Boolean + get() = + dolbyEffect.dsOn.also { + dlog(TAG, "getDsOn: $it") + } + set(value) { + dlog(TAG, "setDsOn: $value") + checkEffect() + dolbyEffect.dsOn = value + registerCallbacks = value + if (value) + setCurrentProfile() + } + + var profile: Int + get() = + dolbyEffect.profile.also { + dlog(TAG, "getProfile: $it") + } + set(value) { + dlog(TAG, "setProfile: $value") + checkEffect() + dolbyEffect.profile = value + } + + init { + dlog(TAG, "initialized") + } + + fun onBootCompleted() { + dlog(TAG, "onBootCompleted") + + // Restore our main settings + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + dsOn = prefs.getBoolean(DolbyConstants.PREF_ENABLE, true) + + context.resources.getStringArray(R.array.dolby_profile_values) + .map { it.toInt() } + .forEach { profile -> + // Reset dolby first to prevent it from loading bad settings + dolbyEffect.resetProfileSpecificSettings(profile) + // Now restore our profile-specific settings + restoreSettings(profile) + } + + // Finally restore the current profile. + setCurrentProfile() + } + + private fun restoreSettings(profile: Int) { + dlog(TAG, "restoreSettings(profile=$profile)") + val prefs = context.getSharedPreferences("profile_$profile", Context.MODE_PRIVATE) + setPreset( + prefs.getString(DolbyConstants.PREF_PRESET, getPreset(profile))!!, + profile + ) + setIeqPreset( + prefs.getString( + DolbyConstants.PREF_IEQ, + getIeqPreset(profile).toString() + )!!.toInt(), + profile + ) + setHeadphoneVirtEnabled( + prefs.getBoolean(DolbyConstants.PREF_HP_VIRTUALIZER, getHeadphoneVirtEnabled(profile)), + profile + ) + setSpeakerVirtEnabled( + prefs.getBoolean(DolbyConstants.PREF_SPK_VIRTUALIZER, getSpeakerVirtEnabled(profile)), + profile + ) + setStereoWideningAmount( + prefs.getString( + DolbyConstants.PREF_STEREO, + getStereoWideningAmount(profile).toString() + )!!.toInt(), + profile + ) + setDialogueEnhancerAmount( + prefs.getString( + DolbyConstants.PREF_DIALOGUE, + getDialogueEnhancerAmount(profile).toString() + )!!.toInt(), + profile + ) + setBassEnhancerEnabled( + prefs.getBoolean(DolbyConstants.PREF_BASS, getBassEnhancerEnabled(profile)), + profile + ) + setVolumeLevelerEnabled( + prefs.getBoolean(DolbyConstants.PREF_VOLUME, getVolumeLevelerEnabled(profile)), + profile + ) + } + + private fun checkEffect() { + if (!dolbyEffect.hasControl()) { + Log.w(TAG, "lost control, recreating effect") + dolbyEffect.release() + dolbyEffect = DolbyAudioEffect(EFFECT_PRIORITY, audioSession = 0) + } + } + + private fun setCurrentProfile() { + dlog(TAG, "setCurrentProfile") + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + profile = prefs.getString(DolbyConstants.PREF_PROFILE, "0" /*dynamic*/)!!.toInt() + } + + fun getProfileName(): String? { + val profile = dolbyEffect.profile.toString() + val profiles = context.resources.getStringArray(R.array.dolby_profile_values) + val profileIndex = profiles.indexOf(profile) + dlog(TAG, "getProfileName: profile=$profile index=$profileIndex") + return if (profileIndex == -1) null else context.resources.getStringArray( + R.array.dolby_profile_entries + )[profileIndex] + } + + fun resetProfileSpecificSettings() { + dlog(TAG, "resetProfileSpecificSettings") + checkEffect() + dolbyEffect.resetProfileSpecificSettings() + context.deleteSharedPreferences("profile_$profile") + } + + fun getPreset(profile: Int = this.profile): String { + val gains = dolbyEffect.getDapParameter(DsParam.GEQ_BAND_GAINS, profile) + return gains.joinToString(separator = ",").also { + dlog(TAG, "getPreset: $it") + } + } + + fun setPreset(value: String, profile: Int = this.profile) { + dlog(TAG, "setPreset: $value") + checkEffect() + val gains = value.split(",") + .map { it.toInt() } + .toIntArray() + dolbyEffect.setDapParameter(DsParam.GEQ_BAND_GAINS, gains, profile) + } + + fun getPresetName(): String { + val presets = context.resources.getStringArray(R.array.dolby_preset_values) + val presetIndex = presets.indexOf(getPreset()) + return if (presetIndex == -1) { + "Custom" + } else { + context.resources.getStringArray( + R.array.dolby_preset_entries + )[presetIndex] + } + } + + fun getHeadphoneVirtEnabled(profile: Int = this.profile) = + dolbyEffect.getDapParameterBool(DsParam.HEADPHONE_VIRTUALIZER, profile).also { + dlog(TAG, "getHeadphoneVirtEnabled: $it") + } + + fun setHeadphoneVirtEnabled(value: Boolean, profile: Int = this.profile) { + dlog(TAG, "setHeadphoneVirtEnabled: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.HEADPHONE_VIRTUALIZER, value, profile) + } + + fun getSpeakerVirtEnabled(profile: Int = this.profile) = + dolbyEffect.getDapParameterBool(DsParam.SPEAKER_VIRTUALIZER, profile).also { + dlog(TAG, "getSpeakerVirtEnabled: $it") + } + + fun setSpeakerVirtEnabled(value: Boolean, profile: Int = this.profile) { + dlog(TAG, "setSpeakerVirtEnabled: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.SPEAKER_VIRTUALIZER, value, profile) + } + + fun getBassEnhancerEnabled(profile: Int = this.profile) = + dolbyEffect.getDapParameterBool(DsParam.BASS_ENHANCER_ENABLE, profile).also { + dlog(TAG, "getBassEnhancerEnabled: $it") + } + + fun setBassEnhancerEnabled(value: Boolean, profile: Int = this.profile) { + dlog(TAG, "setBassEnhancerEnabled: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.BASS_ENHANCER_ENABLE, value, profile) + } + + fun getVolumeLevelerEnabled(profile: Int = this.profile) = + dolbyEffect.getDapParameterBool(DsParam.VOLUME_LEVELER_ENABLE, profile).also { + dlog(TAG, "getVolumeLevelerEnabled: $it") + } + + fun setVolumeLevelerEnabled(value: Boolean, profile: Int = this.profile) { + dlog(TAG, "setVolumeLevelerEnabled: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.VOLUME_LEVELER_ENABLE, value, profile) + } + + fun getStereoWideningAmount(profile: Int = this.profile) = + dolbyEffect.getDapParameterInt(DsParam.STEREO_WIDENING_AMOUNT, profile).also { + dlog(TAG, "getStereoWideningAmount: $it") + } + + fun setStereoWideningAmount(value: Int, profile: Int = this.profile) { + dlog(TAG, "setStereoWideningAmount: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.STEREO_WIDENING_AMOUNT, value, profile) + } + + fun getDialogueEnhancerAmount(profile: Int = this.profile): Int { + val enabled = dolbyEffect.getDapParameterBool(DsParam.DIALOGUE_ENHANCER_ENABLE, profile) + val amount = if (enabled) { + dolbyEffect.getDapParameterInt(DsParam.DIALOGUE_ENHANCER_AMOUNT, profile) + } else 0 + dlog(TAG, "getDialogueEnhancerAmount: enabled=$enabled amount=$amount") + return amount + } + + fun setDialogueEnhancerAmount(value: Int, profile: Int = this.profile) { + dlog(TAG, "setDialogueEnhancerAmount: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.DIALOGUE_ENHANCER_ENABLE, (value > 0), profile) + dolbyEffect.setDapParameter(DsParam.DIALOGUE_ENHANCER_AMOUNT, value, profile) + } + + fun getIeqPreset(profile: Int = this.profile) = + dolbyEffect.getDapParameterInt(DsParam.IEQ_PRESET, profile).also { + dlog(TAG, "getIeqPreset: $it") + } + + fun setIeqPreset(value: Int, profile: Int = this.profile) { + dlog(TAG, "setIeqPreset: $value") + checkEffect() + dolbyEffect.setDapParameter(DsParam.IEQ_PRESET, value, profile) + } + + companion object { + private const val TAG = "DolbyController" + private const val EFFECT_PRIORITY = 100 + + @Volatile + private var instance: DolbyController? = null + + fun getInstance(context: Context) = + instance ?: synchronized(this) { + instance ?: DolbyController(context).also { instance = it } + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyTileService.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyTileService.kt new file mode 100644 index 0000000..70b2fe3 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/DolbyTileService.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService + +private const val TAG = "DolbyTileService" + +class DolbyTileService : TileService() { + + private val dolbyController by lazy { DolbyController.getInstance(applicationContext) } + + override fun onStartListening() { + qsTile.apply { + state = if (dolbyController.dsOn) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + subtitle = dolbyController.getProfileName() ?: getString(R.string.dolby_unknown) + updateTile() + } + super.onStartListening() + } + + override fun onClick() { + val isDsOn = dolbyController.dsOn + dolbyController.dsOn = !isDsOn + qsTile.apply { + state = if (isDsOn) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE + updateTile() + } + super.onClick() + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/SummaryProvider.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/SummaryProvider.kt new file mode 100644 index 0000000..47dc390 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/SummaryProvider.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import co.aospa.dolby.xiaomi.R +import com.android.settingslib.drawer.TileUtils.META_DATA_PREFERENCE_SUMMARY + +private const val KEY_DOLBY = "dolby" + +/** Provide preference summary for injected items. */ +class SummaryProvider : ContentProvider() { + + override fun call( + method: String, + arg: String?, + extras: Bundle? + ): Bundle? { + val summary = when (method) { + KEY_DOLBY -> getDolbySummary() + else -> return null + } + return Bundle().apply { + putString(META_DATA_PREFERENCE_SUMMARY, summary) + } + } + + override fun onCreate(): Boolean = true + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 + + private fun getDolbySummary(): String { + val dolbyController = DolbyController.getInstance(context!!) + if (!dolbyController.dsOn) { + return context!!.getString(R.string.dolby_off) + } + return dolbyController.getProfileName()?.let { + context!!.getString(R.string.dolby_on_with_profile, it) + } ?: context!!.getString(R.string.dolby_on) + } + +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/EqualizerActivity.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/EqualizerActivity.kt new file mode 100644 index 0000000..30babc3 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/EqualizerActivity.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.rememberNavController +import co.aospa.dolby.xiaomi.R +import co.aospa.dolby.xiaomi.geq.ui.EqualizerScreen +import co.aospa.dolby.xiaomi.geq.ui.EqualizerViewModel +import com.android.settingslib.spa.framework.compose.localNavController +import com.android.settingslib.spa.framework.theme.SettingsTheme +import com.android.settingslib.spa.widget.scaffold.SettingsScaffold + +class EqualizerActivity : ComponentActivity() { + + private val viewModel: EqualizerViewModel by viewModels { EqualizerViewModel.Factory } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SettingsTheme { + MainContent() + } + } + } + + @Composable + private fun MainContent() { + val navController = rememberNavController() + CompositionLocalProvider(navController.localNavController()) { + SettingsScaffold( + title = stringResource(id = R.string.dolby_preset) + ) { paddingValues -> + EqualizerScreen( + viewModel = viewModel, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/BandGain.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/BandGain.kt new file mode 100644 index 0000000..6413535 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/BandGain.kt @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.data + +data class BandGain( + val band: Int, + var gain: Int = 0 +) diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/EqualizerRepository.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/EqualizerRepository.kt new file mode 100644 index 0000000..ca6ca2e --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/EqualizerRepository.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.data + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_PRESET +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog +import co.aospa.dolby.xiaomi.DolbyController +import co.aospa.dolby.xiaomi.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext + +class EqualizerRepository( + private val context: Context +) { + + private val dolbyController by lazy { DolbyController.getInstance(context) } + + // Preset is saved as a string of comma separated gains in SharedPreferences + // and is unique to each profile ID + private val profile = dolbyController.profile + private val profileSharedPrefs by lazy { + context.getSharedPreferences( + "profile_$profile", + Context.MODE_PRIVATE + ) + } + + private val presetsSharedPrefs by lazy { + context.getSharedPreferences( + "presets", + Context.MODE_PRIVATE + ) + } + + val builtInPresets: List by lazy { + val names = context.resources.getStringArray( + R.array.dolby_preset_entries + ) + val presets = context.resources.getStringArray( + R.array.dolby_preset_values + ) + List(names.size) { index -> + Preset( + name = names[index], + bandGains = deserializeGains(presets[index]), + ) + } + } + + val defaultPreset by lazy { builtInPresets[0] } // Flat + + // User defined presets are stored in a SharedPreferences as + // key - preset name + // value - comma separated string of gains + val userPresets: Flow> = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> + dlog(TAG, "presetsSharedPrefs changed") + trySend( + presetsSharedPrefs.all.map { (key, value) -> + Preset( + name = key, + bandGains = deserializeGains(value.toString()), + isUserDefined = true + ) + } + ) + } + + presetsSharedPrefs.registerOnSharedPreferenceChangeListener(listener) + dlog(TAG, "presetsSharedPrefs registered listener") + // trigger an initial emission + listener.onSharedPreferenceChanged(presetsSharedPrefs, null) + + awaitClose { + presetsSharedPrefs.unregisterOnSharedPreferenceChangeListener(listener) + dlog(TAG, "presetsSharedPrefs unregistered listener") + } + } + + suspend fun getBandGains(): List = withContext(Dispatchers.IO) { + val gains = profileSharedPrefs.getString(PREF_PRESET, dolbyController.getPreset()) + return@withContext if (gains.isNullOrEmpty()) { + defaultPreset.bandGains + } else { + deserializeGains(gains) + }.also { + dlog(TAG, "getBandGains: $it") + } + } + + suspend fun setBandGains(bandGains: List) = withContext(Dispatchers.IO) { + dlog(TAG, "setBandGains($bandGains)") + val gains = serializeGains(bandGains) + dolbyController.setPreset(gains) + profileSharedPrefs.edit() + .putString(PREF_PRESET, gains) + .apply() + } + + suspend fun addPreset(preset: Preset) = withContext(Dispatchers.IO) { + dlog(TAG, "addPreset($preset)") + presetsSharedPrefs.edit() + .putString(preset.name, serializeGains(preset.bandGains)) + .apply() + } + + suspend fun removePreset(preset: Preset) = withContext(Dispatchers.IO) { + dlog(TAG, "removePreset($preset)") + presetsSharedPrefs.edit() + .remove(preset.name) + .apply() + } + + private companion object { + const val TAG = "EqRepository" + + val tenBandFreqs = intArrayOf( + 32, + 64, + 125, + 250, + 500, + 1000, + 2000, + 4000, + 8000, + 16000 + ) + + fun deserializeGains(bandGains: String): List { + val gains: List = + bandGains.split(",").runCatching { + require(size == 20) { + "Preset must have 20 elements, has only $size!" + } + map { it.toInt() } + .twentyToTenBandGains() + }.onFailure { exception -> + Log.e(TAG, "Failed to parse preset", exception) + }.getOrDefault( + // fallback to flat + List(10) { 0 } + ) + return List(10) { index -> + BandGain( + band = tenBandFreqs[index], + gain = gains[index] + ) + } + } + + fun serializeGains(bandGains: List): String { + return bandGains.map { it.gain } + .tenToTwentyBandGains() + .joinToString(",") + } + + // we show only 10 bands in UI however backend requires 20 bands + fun List.tenToTwentyBandGains() = + List(20) { index -> + if (index % 2 == 1 && index < 19) { + // every odd element is the average of its surrounding elements + (this[(index - 1) / 2] + this[(index + 1) / 2]) / 2 + } else { + this[index / 2] + } + } + + fun List.twentyToTenBandGains() = + // skip every odd element + filterIndexed { index, _ -> index % 2 == 0 } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/Preset.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/Preset.kt new file mode 100644 index 0000000..a7b4d3c --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/data/Preset.kt @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.data + +data class Preset( + var name: String, + val bandGains: List, + var isUserDefined: Boolean = false, + var isMutated: Boolean = false +) diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSlider.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSlider.kt new file mode 100644 index 0000000..ca6e41a --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSlider.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import co.aospa.dolby.xiaomi.geq.data.BandGain + +@Composable +fun BandGainSlider( + bandGain: BandGain, + onValueChangeFinished: (Int) -> Unit +) { + // Gain range is of -1->1 in UI, -100->100 in backend, but actually is -10->10 dB. + + // Ensure we update the slider when gain is changed, + // for eg. when changing the preset + var sliderPosition by remember(bandGain.gain) { + mutableFloatStateOf(bandGain.gain / 100f) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SliderText( + "%.1f".format(sliderPosition * 10f) + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + onValueChangeFinished((sliderPosition * 100f).toInt()) + }, + valueRange = -1f..1f, + modifier = Modifier + .graphicsLayer { + rotationZ = 270f + transformOrigin = TransformOrigin(0f, 0f) + } + .layout { measurable, constraints -> + val placeable = measurable.measure( + Constraints( + minWidth = constraints.minHeight, + maxWidth = constraints.maxHeight, + minHeight = constraints.minWidth, + maxHeight = constraints.maxHeight, + ) + ) + layout(placeable.height, placeable.width) { + placeable.place(-placeable.width, 0) + } + } + // horizontal and vertical dimensions are inverted due to rotation + .width(200.dp) + .height(40.dp) + .padding(8.dp) + ) + SliderText( + with(bandGain.band) { + if (this >= 1000) { + "${this / 1000}k" + } else { + "$this" + } + } + ) + } +} + +@Composable +fun SliderText( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + modifier = modifier, + fontSize = 12.sp + ) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSliderLabels.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSliderLabels.kt new file mode 100644 index 0000000..512c138 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/BandGainSliderLabels.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import co.aospa.dolby.xiaomi.R + +@Composable +fun BandGainSliderLabels() { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.padding(end = 8.dp) + ) { + LabelText( + stringResource(id = R.string.dolby_geq_slider_label_gain) + ) + Column( + modifier = Modifier.height(200.dp), + horizontalAlignment = Alignment.End + ) { + LabelText( + "+10 dB", + modifier = Modifier.padding( + top = 10.dp + ) + ) + Spacer( + modifier = Modifier.weight(1f) + ) + LabelText("0 dB") + Spacer( + modifier = Modifier.weight(1f) + ) + LabelText( + "-10 dB", + modifier = Modifier.padding( + bottom = 10.dp + ) + ) + } + LabelText("Hz") + } +} + +@Composable +fun LabelText( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + modifier = modifier, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp + ) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/ConfirmationDialog.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/ConfirmationDialog.kt new file mode 100644 index 0000000..bee6704 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/ConfirmationDialog.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource + +@Composable +fun ConfirmationDialog( + text: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + var showDialog by remember { mutableStateOf(true) } + if (!showDialog) { + onDismiss() + return + } + + AlertDialog( + onDismissRequest = { showDialog = false }, + confirmButton = { + TextButton( + onClick = { + showDialog = false + onConfirm() + } + ) { + Text( + stringResource(id = android.R.string.ok) + ) + } + }, + dismissButton = { + TextButton( + onClick = { showDialog = false } + ) { + Text( + stringResource(id = android.R.string.cancel) + ) + } + }, + text = { + Text(text) + } + ) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerBands.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerBands.kt new file mode 100644 index 0000000..a01671e --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerBands.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun EqualizerBands(viewModel: EqualizerViewModel) { + val preset by viewModel.preset.collectAsState() + val bandGains = preset.bandGains + + LazyRow( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + item { + BandGainSliderLabels() + } + items(bandGains.size) { index -> + BandGainSlider( + bandGains[index], + onValueChangeFinished = { + viewModel.setGain(index, it) + } + ) + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerScreen.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerScreen.kt new file mode 100644 index 0000000..95b8b78 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerScreen.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.settingslib.spa.framework.theme.SettingsDimension +import com.android.settingslib.spa.framework.theme.SettingsTheme + +@Composable +fun EqualizerScreen( + viewModel: EqualizerViewModel, + modifier: Modifier = Modifier +) { + Surface( + modifier = Modifier + .fillMaxSize() + .padding(SettingsDimension.itemPadding) + .then(modifier), + color = SettingsTheme.colorScheme.background + ) { + Column( + verticalArrangement = Arrangement.Top, + modifier = Modifier.fillMaxHeight() + ) { + PresetSelector(viewModel = viewModel) + EqualizerBands(viewModel = viewModel) + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerViewModel.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerViewModel.kt new file mode 100644 index 0000000..0d19341 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/EqualizerViewModel.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.aospa.dolby.xiaomi.geq.data.EqualizerRepository +import co.aospa.dolby.xiaomi.geq.data.Preset +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +const val TAG = "EqViewModel" + +class EqualizerViewModel( + private val repository: EqualizerRepository +) : ViewModel() { + + private val _presets = MutableStateFlow(repository.builtInPresets) + val presets = _presets.asStateFlow() + + private val _preset = MutableStateFlow(repository.defaultPreset) + val preset = _preset.asStateFlow() + + private var presetRestored = false + + init { + // Update the list of presets: combined list of user defined presets if any, + // and then the built in presets. + repository.userPresets + .onEach { presets -> + dlog(TAG, "updated userPresets: $presets") + _presets.value = mutableListOf().apply { + addAll(presets) + addAll(repository.builtInPresets) + }.toList() + + // We can restore the active preset only after the presets list is populated, + // since we do not save the preset name but only its gains. + if (!presetRestored) { + val bandGains = repository.getBandGains() + _preset.value = _presets.value.find { + bandGains == it.bandGains + } ?: Preset( + name = "Custom", + bandGains = bandGains + ) + dlog(TAG, "restored preset: ${_preset.value}") + presetRestored = true + } + } + .launchIn(viewModelScope) + + // Update the preset in repository everytime we set it here + _preset + .drop(1) // skip the initial value + .onEach { + // wait till the active preset is restored + if (!presetRestored) { + return@onEach + } + dlog(TAG, "updated preset: $it") + repository.setBandGains(it.bandGains) + if (it.isUserDefined) { + repository.addPreset(it) + } + } + .launchIn(viewModelScope) + } + + fun reset() { + dlog(TAG, "reset()") + if (_preset.value.isUserDefined) { + // Reset gains to 0 + _preset.value = _preset.value.copy( + bandGains = repository.defaultPreset.bandGains + ) + } else { + // Switch to flat preset + _preset.value = repository.defaultPreset + } + } + + fun setPreset(preset: Preset) { + dlog(TAG, "setPreset($preset)") + _preset.value = preset + } + + fun setGain(index: Int, gain: Int) { + dlog(TAG, "setGain($index, $gain)") + _preset.value = _preset.value.run { + copy( + name = if (!isUserDefined) "Custom" else name, + bandGains = bandGains + .toMutableList() + // create a new object to ensure the flow emits an update. + .apply { this[index] = this[index].copy(gain = gain) } + .toList(), + isMutated = true + ) + } + } + + // Returns string containing the error message if it failed, otherwise null + private fun validatePresetName(name: String): PresetNameValidationError? { + // Ensure we don't have another preset with the same name + return if ( + _presets.value + .any { it.name.equals(name.trim(), ignoreCase = true) } + ) { + PresetNameValidationError.NAME_EXISTS + } else if (name.length > 50) { + PresetNameValidationError.NAME_TOO_LONG + } else null + } + + fun createNewPreset(name: String): PresetNameValidationError? { + dlog(TAG, "createNewPreset($name)") + validatePresetName(name)?.let { + dlog(TAG, "createNewPreset failed: $it") + return it + } + _preset.value = _preset.value.copy( + name = name.trim(), + isUserDefined = true, + isMutated = false + ) + return null + } + + fun renamePreset(preset: Preset, name: String): PresetNameValidationError? { + dlog(TAG, "renamePreset($preset, $name)") + // create a preset with the new name and same gains + createNewPreset(name = name)?.let { + dlog(TAG, "renamePreset failed") + return it + } + // and delete the old one. + deletePreset(preset, shouldReset = false) + return null + } + + fun deletePreset(preset: Preset, shouldReset: Boolean = true) { + dlog(TAG, "deletePreset($preset)") + viewModelScope.launch { + repository.removePreset(preset) + } + if (shouldReset) { + _preset.value = repository.defaultPreset + } + } + + companion object { + val Factory = viewModelFactory { + initializer { + EqualizerViewModel( + repository = EqualizerRepository( + this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]!! + ) + ) + } + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameDialog.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameDialog.kt new file mode 100644 index 0000000..00843b2 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameDialog.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import co.aospa.dolby.xiaomi.R + +@Composable +fun PresetNameDialog( + title: String, + presetName: String = "", + onPresetNameSet: (String) -> PresetNameValidationError?, + onDismissDialog: () -> Unit +) { + var showDialog by remember { mutableStateOf(true) } + if (!showDialog) { + onDismissDialog() + return + } + var text by remember { mutableStateOf(presetName) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = { showDialog = false }, + confirmButton = { + TextButton( + onClick = { + onPresetNameSet(text)?.let { + // validation failed + error = it + return@TextButton + } + // succeeded + showDialog = false + error = null + } + ) { + Text( + stringResource(id = android.R.string.ok) + ) + } + }, + dismissButton = { + TextButton( + onClick = { showDialog = false } + ) { + Text( + stringResource(id = android.R.string.cancel) + ) + } + }, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { + Text( + stringResource(id = R.string.dolby_geq_preset_name) + ) + }, + isError = error != null, + singleLine = true + ) + error?.let { + Text( + text = it.toErrorMessage(), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + ) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameValidationError.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameValidationError.kt new file mode 100644 index 0000000..739b1c8 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetNameValidationError.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import co.aospa.dolby.xiaomi.R + +enum class PresetNameValidationError { + NAME_EXISTS, + NAME_TOO_LONG; + + @Composable + fun toErrorMessage() = + stringResource( + id = when (this) { + NAME_EXISTS -> R.string.dolby_geq_preset_name_exists + NAME_TOO_LONG -> R.string.dolby_geq_preset_name_too_long + } + ) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetSelector.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetSelector.kt new file mode 100644 index 0000000..5275e19 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/PresetSelector.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import co.aospa.dolby.xiaomi.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PresetSelector(viewModel: EqualizerViewModel) { + val presets by viewModel.presets.collectAsState() + val currentPreset by viewModel.preset.collectAsState() + var expanded by remember { mutableStateOf(false) } + var showNewPresetDialog by remember { mutableStateOf(false) } + var showRenamePresetDialog by remember { mutableStateOf(false) } + var showDeleteConfirmDialog by remember { mutableStateOf(false) } + var showResetConfirmDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier + .padding(end = 8.dp) + .weight(1f) + ) { + TextField( + value = currentPreset.name, + onValueChange = { }, + readOnly = true, + label = { + Text( + stringResource(id = R.string.dolby_geq_preset) + ) + }, + singleLine = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor() + // prevent keyboard from popping up + .focusProperties { canFocus = false } + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + presets.forEach { preset -> + DropdownMenuItem( + text = { Text(text = preset.name) }, + onClick = { + viewModel.setPreset(preset) + expanded = false + } + ) + } + } + } + + TooltipIconButton( + icon = ImageVector.vectorResource( + id = R.drawable.save_as_24px + ), + text = stringResource(id = R.string.dolby_geq_new_preset), + onClick = { showNewPresetDialog = true } + ) + + if (currentPreset.isUserDefined) { + TooltipIconButton( + icon = Icons.Default.Edit, + text = stringResource(id = R.string.dolby_geq_rename_preset), + onClick = { showRenamePresetDialog = true } + ) + TooltipIconButton( + icon = Icons.Default.Delete, + text = stringResource(id = R.string.dolby_geq_delete_preset), + onClick = { showDeleteConfirmDialog = true } + ) + } + + TooltipIconButton( + icon = ImageVector.vectorResource( + id = R.drawable.reset_settings_24px + ), + text = stringResource(id = R.string.dolby_geq_reset_gains), + onClick = { + if (currentPreset.isUserDefined) { + showResetConfirmDialog = true + } else { + viewModel.reset() + } + } + ) + } + + // Dialogs + + if (showNewPresetDialog) { + PresetNameDialog( + title = stringResource(id = R.string.dolby_geq_new_preset), + onPresetNameSet = { + return@PresetNameDialog viewModel.createNewPreset(name = it) + }, + onDismissDialog = { showNewPresetDialog = false } + ) + } + + if (showRenamePresetDialog) { + PresetNameDialog( + title = stringResource(id = R.string.dolby_geq_rename_preset), + presetName = currentPreset.name, + onPresetNameSet = { + return@PresetNameDialog viewModel.renamePreset( + preset = currentPreset, + name = it + ) + }, + onDismissDialog = { showRenamePresetDialog = false } + ) + } + + if (showDeleteConfirmDialog) { + ConfirmationDialog( + text = stringResource(id = R.string.dolby_geq_delete_preset_prompt), + onConfirm = { viewModel.deletePreset(currentPreset) }, + onDismiss = { showDeleteConfirmDialog = false } + ) + } + + if (showResetConfirmDialog) { + ConfirmationDialog( + text = stringResource(id = R.string.dolby_geq_reset_gains_prompt), + onConfirm = { viewModel.reset() }, + onDismiss = { showResetConfirmDialog = false } + ) + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/TooltipIconButton.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/TooltipIconButton.kt new file mode 100644 index 0000000..ef9906d --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/geq/ui/TooltipIconButton.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.geq.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TooltipIconButton( + icon: ImageVector, + text: String, + onClick: () -> Unit +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + Text(text) + }, + state = rememberTooltipState() + ) { + IconButton( + onClick = onClick + ) { + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier.size(24.dp) + ) + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyIeqPreference.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyIeqPreference.kt new file mode 100644 index 0000000..215fa6f --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyIeqPreference.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.preference + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import androidx.appcompat.content.res.AppCompatResources +import androidx.preference.ListPreference +import androidx.preference.PreferenceViewHolder +import co.aospa.dolby.xiaomi.R + +// Preference with icon on the right side +class DolbyIeqPreference( + context: Context, + attrs: AttributeSet?, +) : ListPreference(context, attrs) { + + init { + widgetLayoutResource = R.layout.ieq_icon_layout + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val iconView = holder.findViewById(R.id.ieq_icon)!! as ImageView + val icon = AppCompatResources.getDrawable(context, getIeqIconResId()) + iconView.setImageDrawable(icon) + } + + private fun getIeqIconResId(): Int { + val ieqValue = value?.toIntOrNull() ?: 0 + return when (ieqValue) { + 0 -> R.drawable.ic_ieq_off + 1 -> R.drawable.ic_ieq_balanced + 2 -> R.drawable.ic_ieq_warm + 3 -> R.drawable.ic_ieq_detailed + else -> 0 // should never hit this! + } + } +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyPreferenceStore.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyPreferenceStore.kt new file mode 100644 index 0000000..627e639 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbyPreferenceStore.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.preference + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceDataStore +import androidx.preference.PreferenceManager +import co.aospa.dolby.xiaomi.DolbyConstants + +class DolbyPreferenceStore( + private val context: Context +) : PreferenceDataStore() { + + private val defaultSharedPrefs by lazy { + PreferenceManager.getDefaultSharedPreferences(context) + } + + private lateinit var profileSharedPrefs: SharedPreferences + + var profile = 0 + set(value) { + field = value + profileSharedPrefs = context.getSharedPreferences( + "profile_$value", + Context.MODE_PRIVATE + ) + } + + private fun getSharedPreferences(key: String) = + if (DolbyConstants.PROFILE_SPECIFIC_PREFS.contains(key)) { + profileSharedPrefs + } else { + defaultSharedPrefs + } + + override fun putBoolean(key: String, value: Boolean) = + getSharedPreferences(key).edit() + .putBoolean(key, value) + .apply() + + override fun getBoolean(key: String, defValue: Boolean) = + getSharedPreferences(key).getBoolean(key, defValue) + + override fun putInt(key: String, value: Int) = + getSharedPreferences(key).edit() + .putInt(key, value) + .apply() + + override fun getInt(key: String, defValue: Int) = + getSharedPreferences(key).getInt(key, defValue) + + override fun putString(key: String, value: String?) = + getSharedPreferences(key).edit() + .putString(key, value) + .apply() + + override fun getString(key: String, defValue: String?) = + getSharedPreferences(key).getString(key, defValue) +} diff --git a/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbySettingsFragment.kt b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbySettingsFragment.kt new file mode 100644 index 0000000..643e553 --- /dev/null +++ b/XiaomiDolby/src/co/aospa/dolby/xiaomi/preference/DolbySettingsFragment.kt @@ -0,0 +1,299 @@ +/* + * Copyright (C) 2023-24 Paranoid Android + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package co.aospa.dolby.xiaomi.preference + +import android.media.AudioAttributes +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Bundle +import android.os.Handler +import android.widget.CompoundButton +import android.widget.CompoundButton.OnCheckedChangeListener +import android.widget.Toast +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.Preference.OnPreferenceChangeListener +import androidx.preference.PreferenceFragment +import androidx.preference.SwitchPreferenceCompat +import co.aospa.dolby.xiaomi.DolbyConstants +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_BASS +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_DIALOGUE +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_ENABLE +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_HP_VIRTUALIZER +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_IEQ +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_PRESET +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_PROFILE +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_RESET +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_SPK_VIRTUALIZER +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_STEREO +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.PREF_VOLUME +import co.aospa.dolby.xiaomi.DolbyConstants.Companion.dlog +import co.aospa.dolby.xiaomi.DolbyController +import co.aospa.dolby.xiaomi.R +import com.android.settingslib.widget.MainSwitchPreference + +class DolbySettingsFragment : PreferenceFragment(), + OnPreferenceChangeListener, CompoundButton.OnCheckedChangeListener { + + private val switchBar by lazy { + findPreference(PREF_ENABLE)!! + } + private val profilePref by lazy { + findPreference(PREF_PROFILE)!! + } + private val presetPref by lazy { + findPreference(PREF_PRESET)!! + } + private val ieqPref by lazy { + findPreference(PREF_IEQ)!! + } + private val stereoPref by lazy { + findPreference(PREF_STEREO)!! + } + private val dialoguePref by lazy { + findPreference(PREF_DIALOGUE)!! + } + private val bassPref by lazy { + findPreference(PREF_BASS)!! + } + private val hpVirtPref by lazy { + findPreference(PREF_HP_VIRTUALIZER)!! + } + private val spkVirtPref by lazy { + findPreference(PREF_SPK_VIRTUALIZER)!! + } + private val volumePref by lazy { + findPreference(PREF_VOLUME)!! + } + private val resetPref by lazy { + findPreference(PREF_RESET)!! + } + + private val dolbyController by lazy { DolbyController.getInstance(context) } + private val audioManager by lazy { context.getSystemService(AudioManager::class.java) } + private val handler = Handler() + + private var isOnSpeaker = true + set(value) { + if (field == value) return + field = value + dlog(TAG, "setIsOnSpeaker($value)") + updateProfileSpecificPrefs() + } + + private val audioDeviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + dlog(TAG, "onAudioDevicesAdded") + updateSpeakerState() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + dlog(TAG, "onAudioDevicesRemoved") + updateSpeakerState() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + dlog(TAG, "onCreatePreferences") + addPreferencesFromResource(R.xml.dolby_settings) + + val profile = dolbyController.profile + preferenceManager.preferenceDataStore = DolbyPreferenceStore(context).also { + it.profile = profile + } + + val dsOn = dolbyController.dsOn + switchBar.addOnSwitchChangeListener(this) + switchBar.setChecked(dsOn) + + profilePref.onPreferenceChangeListener = this + profilePref.setEnabled(dsOn) + profilePref.apply { + if (entryValues.contains(profile.toString())) { + summary = "%s" + value = profile.toString() + } else { + summary = context.getString(R.string.dolby_unknown) + } + } + + hpVirtPref.onPreferenceChangeListener = this + spkVirtPref.onPreferenceChangeListener = this + stereoPref.onPreferenceChangeListener = this + dialoguePref.onPreferenceChangeListener = this + bassPref.onPreferenceChangeListener = this + volumePref.onPreferenceChangeListener = this + ieqPref.onPreferenceChangeListener = this + + resetPref.setOnPreferenceClickListener { + dolbyController.resetProfileSpecificSettings() + updateProfileSpecificPrefs() + Toast.makeText( + context, + context.getString(R.string.dolby_reset_profile_toast, profilePref.summary), + Toast.LENGTH_SHORT + ).show() + true + } + + audioManager!!.registerAudioDeviceCallback(audioDeviceCallback, handler) + updateSpeakerState() + updateProfileSpecificPrefs() + } + + override fun onDestroyView() { + dlog(TAG, "onDestroyView") + audioManager!!.unregisterAudioDeviceCallback(audioDeviceCallback) + super.onDestroyView() + } + + override fun onResume() { + super.onResume() + updateProfileSpecificPrefs() + } + + override fun onPreferenceChange(preference: Preference, newValue: Any): Boolean { + dlog(TAG, "onPreferenceChange: key=${preference.key} value=$newValue") + when (preference.key) { + PREF_PROFILE -> { + val profile = newValue.toString().toInt() + dolbyController.profile = profile + (preferenceManager.preferenceDataStore as DolbyPreferenceStore).profile = profile + updateProfileSpecificPrefs() + } + + PREF_SPK_VIRTUALIZER -> { + dolbyController.setSpeakerVirtEnabled(newValue as Boolean) + } + + PREF_HP_VIRTUALIZER -> { + dolbyController.setHeadphoneVirtEnabled(newValue as Boolean) + } + + PREF_STEREO -> { + dolbyController.setStereoWideningAmount(newValue.toString().toInt()) + } + + PREF_DIALOGUE -> { + dolbyController.setDialogueEnhancerAmount(newValue.toString().toInt()) + } + + PREF_BASS -> { + dolbyController.setBassEnhancerEnabled(newValue as Boolean) + } + + PREF_VOLUME -> { + dolbyController.setVolumeLevelerEnabled(newValue as Boolean) + } + + PREF_IEQ -> { + dolbyController.setIeqPreset(newValue.toString().toInt()) + } + + else -> return false + } + return true + } + + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + dlog(TAG, "onCheckedChanged($isChecked)") + dolbyController.dsOn = isChecked + profilePref.setEnabled(isChecked) + updateProfileSpecificPrefs() + } + + private fun updateSpeakerState() { + val device = audioManager!!.getDevicesForAttributes(ATTRIBUTES_MEDIA)[0] + isOnSpeaker = (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) + } + + private fun updateProfileSpecificPrefs() { + val unknownRes = context.getString(R.string.dolby_unknown) + val headphoneRes = context.getString(R.string.dolby_connect_headphones) + val dsOn = dolbyController.dsOn + val currentProfile = dolbyController.profile + + dlog( + TAG, "updateProfileSpecificPrefs: dsOn=$dsOn currentProfile=$currentProfile" + + " isOnSpeaker=$isOnSpeaker" + ) + + val enable = dsOn && (currentProfile != -1) + presetPref.setEnabled(enable) + spkVirtPref.setEnabled(enable) + ieqPref.setEnabled(enable) + dialoguePref.setEnabled(enable) + volumePref.setEnabled(enable) + resetPref.setEnabled(enable) + hpVirtPref.setEnabled(enable && !isOnSpeaker) + stereoPref.setEnabled(enable && !isOnSpeaker) + bassPref.setEnabled(enable && !isOnSpeaker) + + if (!enable) return + + presetPref.summary = dolbyController.getPresetName() + + val ieqValue = dolbyController.getIeqPreset(currentProfile) + ieqPref.apply { + if (entryValues.contains(ieqValue.toString())) { + summary = "%s" + value = ieqValue.toString() + } else { + summary = unknownRes + } + } + + val deValue = dolbyController.getDialogueEnhancerAmount(currentProfile).toString() + dialoguePref.apply { + if (entryValues.contains(deValue)) { + summary = "%s" + value = deValue + } else { + summary = unknownRes + } + } + + spkVirtPref.setChecked(dolbyController.getSpeakerVirtEnabled(currentProfile)) + volumePref.setChecked(dolbyController.getVolumeLevelerEnabled(currentProfile)) + + // below prefs are not enabled on loudspeaker + if (isOnSpeaker) { + stereoPref.summary = headphoneRes + bassPref.summary = headphoneRes + hpVirtPref.summary = headphoneRes + return + } + + val swValue = dolbyController.getStereoWideningAmount(currentProfile).toString() + stereoPref.apply { + if (entryValues.contains(swValue)) { + summary = "%s" + value = swValue + } else { + summary = unknownRes + } + } + + bassPref.apply { + setChecked(dolbyController.getBassEnhancerEnabled(currentProfile)) + summary = null + } + + hpVirtPref.apply { + setChecked(dolbyController.getHeadphoneVirtEnabled(currentProfile)) + summary = null + } + } + + companion object { + private const val TAG = "DolbySettingsFragment" + private val ATTRIBUTES_MEDIA = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + } +} diff --git a/dolby.mk b/dolby.mk index 4979541..1443818 100644 --- a/dolby.mk +++ b/dolby.mk @@ -49,6 +49,7 @@ PRODUCT_PACKAGES += \ libstagefright_softomx_plugin.vendor \ # Dolby Props +TARGET_USES_DOLBY := true PRODUCT_VENDOR_PROPERTIES += \ ro.audio.spatializer_enabled=true \ ro.vendor.dolby.dax.version=DAX3_3.6.0.12_r1 \ @@ -60,10 +61,10 @@ ro.audio.monitorRotation=true \ PRODUCT_PACKAGES += \ RemovePackagesDolby -# DaxUI and daxService +# XiaomiDolby and daxService PRODUCT_PACKAGES += \ - DaxUI \ - daxService + XiaomiDolby \ + daxService \ # Dolby Permissions PRODUCT_COPY_FILES += \