From 9ddacd020db231129db26594f644271e1dafa8c7 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:50:54 +0800 Subject: [PATCH 1/5] Feature: Display volume change in connection log This commit introduces the ability to record and display the media volume before a device connects and after it disconnects. - **Database:** - The `Connection` entity is updated with `startVolume` and `endVolume` fields to store volume percentages. - The database version is bumped to `2`, and a migration (`MIGRATION_1_2`) is added to alter the `connection_table` with the new `start_volume` and `end_volume` columns. - **UI:** - A new `TextView` (`text_volume_log`) is added to the `item_connection.xml` layout to display the volume change. - The `ConnectionListAdapter` is updated to bind the `startVolume` and `endVolume` data to the new `TextView`, showing it only when the data is available. --- .../liveinpeace/ConnectionListAdapter.kt | 19 ++++++++-- .../maary/liveinpeace/database/Connection.kt | 4 +++ .../database/ConnectionRoomDatabase.kt | 35 ++++++++++--------- app/src/main/res/layout/item_connection.xml | 13 +++++++ 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt b/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt index 73a4633..bfe998b 100644 --- a/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt +++ b/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt @@ -25,7 +25,12 @@ class ConnectionListAdapter : ListAdapter + + + \ No newline at end of file From 88cf6a151101b9987efffa044e550b8e3c8fd8ce Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:51:18 +0800 Subject: [PATCH 2/5] Feature: Restore system volume after disconnect This commit introduces a new feature that saves the system volume when a wired headset is connected and restores it after disconnection. This includes: - A new toggle in the settings screen to enable or disable this feature. - `DataStore` preferences to store the feature's enabled state and the last known system volume. - Updates to the `SettingsViewModel` and `PreferenceRepository` to handle the logic for storing and retrieving these new settings. --- .../java/com/maary/liveinpeace/Constants.kt | 2 ++ .../database/PreferenceRepository.kt | 24 +++++++++++++++++++ .../liveinpeace/ui/screen/SettingsScreen.kt | 13 ++++++++++ .../viewmodel/SettingsViewModel.kt | 9 +++++++ 4 files changed, 48 insertions(+) diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index 4531ee8..0776dcb 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -12,6 +12,8 @@ class Constants { const val PREF_HIDE_IN_LAUNCHER = "hide_in_launcher" const val PREF_EAR_PROTECTION_THRESHOLD_MAX = "ear_protection_max" const val PREF_EAR_PROTECTION_THRESHOLD_MIN = "ear_protection_min" + const val PREF_RESTORE_VOLUME = "restore_volume_after_disconnect" + const val PREF_LAST_SYSTEM_VOLUME = "last_system_volume_before_connect" const val EAR_PROTECTION_LOWER_THRESHOLD = 10 const val EAR_PROTECTION_UPPER_THRESHOLD = 25 // 前台通知 id diff --git a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt index a6af554..97d0fce 100755 --- a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -36,6 +36,8 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont val PREF_VISIBLE_IN_LAUNCHER = booleanPreferencesKey(Constants.PREF_HIDE_IN_LAUNCHER) val PREF_EAR_PROTECTION_THRESHOLD_MAX = intPreferencesKey(Constants.PREF_EAR_PROTECTION_THRESHOLD_MAX) val PREF_EAR_PROTECTION_THRESHOLD_MIN = intPreferencesKey(Constants.PREF_EAR_PROTECTION_THRESHOLD_MIN) + val PREF_RESTORE_VOLUME = booleanPreferencesKey(Constants.PREF_RESTORE_VOLUME) + val PREF_LAST_SYSTEM_VOLUME = intPreferencesKey(Constants.PREF_LAST_SYSTEM_VOLUME) } fun getWatchingState(): Flow { @@ -126,4 +128,26 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont pref[PREF_EAR_PROTECTION_THRESHOLD_MAX] = range.last } } + + // 获取设置状态 + fun isVolumeRestoreEnabled(): Flow { + return datastore.data.map { pref -> + pref[PREF_RESTORE_VOLUME] ?: false + } + } + + // 切换设置 + suspend fun setVolumeRestoreEnabled(enabled: Boolean) { + datastore.edit { preferences -> preferences[PREF_RESTORE_VOLUME] = enabled } + } + + fun getLastSystemVolume(): Flow = datastore.data + .map { preferences -> preferences[PREF_LAST_SYSTEM_VOLUME] ?: -1 } + + // 更新系统音量记录 + suspend fun saveLastSystemVolume(volume: Int) { + datastore.edit { preferences -> + preferences[PREF_LAST_SYSTEM_VOLUME] = volume + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt index 75f3885..759c554 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt @@ -128,6 +128,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() val isForegroundEnabled by settingsViewModel.foregroundSwitchState.collectAsState() + val isVolumeRestoreEnabled by settingsViewModel.isVolumeRestoreEnabled.collectAsState() Spacer(modifier = Modifier.height(16.dp + innerPadding.calculateTopPadding())) @@ -195,6 +196,18 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { } } + SettingsItem( + position = GroupPosition.MIDDLE , + containerColor = MaterialTheme.colorScheme.secondaryContainer) { + SwitchRow( + title = stringResource(R.string.restore_system_volume), + description = stringResource(R.string.restore_volume_description), + state = isVolumeRestoreEnabled, + ) { + settingsViewModel.toggleVolumeRestore(it) + } + } + SettingsItem(GroupPosition.BOTTOM, containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( diff --git a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt index 2c61601..b39d98d 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -123,6 +123,15 @@ class SettingsViewModel @Inject constructor( } } + val isVolumeRestoreEnabled: StateFlow = preferenceRepository.isVolumeRestoreEnabled() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + fun toggleVolumeRestore(enabled: Boolean) { + viewModelScope.launch { + preferenceRepository.setVolumeRestoreEnabled(enabled) + } + } + init { checkAndSyncServiceState() } From 9dab16bd2c1d20f64f47cb17f715291e9937fe07 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:51:31 +0800 Subject: [PATCH 3/5] Refactor volume restoration logic This commit refactors the volume restoration logic to be more reliable and introduces several key improvements: - **Delayed Volume Restoration:** When a device disconnects, the service now waits 600ms before restoring the volume. This delay allows the system to switch audio output back to the internal speakers, ensuring the correct volume is restored. - **Improved Volume Capture:** - The `startVolume` (volume when a device connects) is now recorded. - The `endVolume` (system volume after a device disconnects) is also recorded. - **System Volume Tracking:** - The service now caches the system's speaker volume when no devices are connected. - This cached volume is used as the `startVolume` for a new connection if it's the first one, providing a more accurate restoration point. - **Conditional Restoration:** The volume restoration now only occurs if all Bluetooth audio devices are disconnected, preventing interference when switching between different headphones. - **Code Cleanup:** Added a `percentageToVolumeIndex` helper function for cleaner volume calculations and removed commented-out legacy code. --- .../liveinpeace/service/ForegroundService.kt | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt index 339987d..bc1a3e2 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -114,6 +115,8 @@ class ForegroundService : Service() { private val volumeIconMap = SparseIntArray() + private var lastKnownSystemVolumeCache: Int = -1 + override fun onCreate() { super.onCreate() Log.d(TAG, "Service creating...") @@ -324,6 +327,9 @@ class ForegroundService : Service() { var wasAdded = false deviceMapMutex.withLock { if (!deviceMap.containsKey(deviceName)) { + val savedVolume = preferenceRepository.getLastSystemVolume().first() + val startVol = if (savedVolume != -1) savedVolume else getVolumePercentage() + Log.d(CALLBACK_TAG, "Device Added: $deviceName") deviceMap[deviceName] = Connection( name = deviceName, @@ -331,7 +337,9 @@ class ForegroundService : Service() { connectedTime = System.currentTimeMillis(), disconnectedTime = null, duration = null, - date = LocalDate.now().toString() + date = LocalDate.now().toString(), + startVolume = startVol, + endVolume = null ) wasAdded = true @@ -365,6 +373,16 @@ class ForegroundService : Service() { disconnectedTime = disconnectedTime, duration = duration ) + +// if (preferenceRepository.isVolumeRestoreEnabled().first()) { +// connection.startVolume?.let { startVol -> +// Log.d(TAG, "Restoring volume to $startVol% for disconnect of $deviceName") +// val targetIndex = percentageToVolumeIndex(startVol) +// // 使用 FLAG_SHOW_UI 可以让用户看到音量变化的滑块,方便调试确认 +// audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, targetIndex, AudioManager.FLAG_SHOW_UI) +// } +// } + if (duration > Constants.ALERT_TIME) { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(Constants.ID_NOTIFICATION_ALERT) @@ -372,6 +390,37 @@ class ForegroundService : Service() { } } } + + // [第二步] 在锁释放后:等待系统切换、读取音量、恢复音量 + // 这样做可以避免阻塞其他设备的连接/断开处理 + if (connectionToSave != null) { + // [核心修改] 延迟 600ms,等待系统将音频输出切换回扬声器并更新音量状态 + // 具体的毫秒数可能因机型而异,500-800ms 通常是安全的 + delay(600) + + // 此时获取的音量应该是系统切换后的音量(即“断开后”的真实状态) + val currentVol = getVolumePercentage() + + // 更新 endVolume + val finalConnection = connectionToSave.copy(endVolume = currentVol) + + // [音量恢复逻辑] + // 再次检查是否所有设备都已断开。 + // 如果此时用户迅速插入了另一个耳机,deviceMap 就不为空,我们就不应该执行“恢复扬声器音量”的操作,以免干扰新设备。 + val isMapEmpty = deviceMapMutex.withLock { deviceMap.isEmpty() } + + if (isMapEmpty && preferenceRepository.isVolumeRestoreEnabled().first()) { + finalConnection.startVolume?.let { startVol -> + Log.d(CALLBACK_TAG, "Restoring volume to $startVol% for disconnect of $deviceName") + // 使用之前定义的辅助函数 percentageToVolumeIndex + val targetIndex = percentageToVolumeIndex(startVol) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, targetIndex, AudioManager.FLAG_SHOW_UI) + } + } + + return finalConnection + } + return connectionToSave } @@ -442,6 +491,19 @@ class ForegroundService : Service() { Log.w(TAG, "Cannot update notification: Permission denied.") return } + + if (deviceMap.isEmpty()) { + val currentVolume = getVolumePercentage() + + if (currentVolume != lastKnownSystemVolumeCache) { + lastKnownSystemVolumeCache = currentVolume + serviceScope.launch { + preferenceRepository.saveLastSystemVolume(currentVolume) + } + Log.d(TAG, "Updated internal system volume record: $currentVolume%") + } + } + NotificationManagerCompat.from(this).notify( Constants.ID_NOTIFICATION_FOREGROUND, createForegroundNotification(this) @@ -532,6 +594,11 @@ class ForegroundService : Service() { return if (maxVolume > 0) 100 * currentVolume / maxVolume else 0 } + private fun percentageToVolumeIndex(percent: Int): Int { + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + return ((percent * maxVolume) + 50) / 100 + } + private fun getVolumeLevel(percent: Int): Int { return when (percent) { 0 -> 0 From 71d1912d3a2485594e208fb6963933f58546e4ad Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:51:54 +0800 Subject: [PATCH 4/5] Add strings for restore volume feature This commit adds new string resources for the "Restore Volume on Disconnect" feature. - `volume_log_format`: A format string to log volume changes. - `restore_system_volume`: The title for the feature toggle. - `restore_volume_description`: A description explaining what the feature does, noting it's experimental. Translations for Simplified Chinese are also included. --- app/src/main/res/values-zh-rCN/strings.xml | 4 ++++ app/src/main/res/values/strings.xml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a0bc8d4..200dc93 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -67,4 +67,8 @@ 安全音量阈值 长时间使用耳机时会受到通知提醒。 连接耳机时,自动将音量调整到合适的范围。 + 音量: %1$s%% -> %2$s%% + 断开时恢复音量 + 设备断开后系统会自动调整音量。\n开启此项将尝试强制恢复到连接前的音量。\n实验性功能,不保证生效。 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73cf850..5c8be19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,4 +71,7 @@ Safe Volume Threshold Get notifications for prolonged headphone use. Automatically adjusts headphone volume to a safe and comfortable level upon connection. + Vol: %1$s%% -> %2$s%% + Restore Volume on Disconnect + The system automatically adjusts volume when devices disconnect. \nEnable this to try restoring the pre-connection volume instead. \nFunctionality is not guaranteed. \ No newline at end of file From a8b3df8001f3869e9140792cc9d923071efa047b Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:52:23 +0800 Subject: [PATCH 5/5] Update dependencies and app version This commit updates various dependencies to their latest versions and increments the app version. **Version Changes:** - `versionCode` increased from `5` to `6`. - `versionName` updated from `2025.09.21` to `2025.12.04`. **Dependency Updates:** - **Kotlin:** - `org.jetbrains.kotlin.android` plugin to `2.2.21`. - `org.jetbrains.kotlin.plugin.compose` plugin to `2.2.21`. - **KSP:** - `com.google.devtools.ksp` plugin to `2.2.21-2.0.4`. - **Dagger Hilt:** - `com.google.dagger.hilt.android` plugin and dependencies to `2.57.2`. - **AndroidX Activity:** - `androidx.activity:activity-ktx` and `androidx.activity:activity-compose` to `1.12.1`. - **AndroidX Lifecycle:** - All `androidx.lifecycle` artifacts to `2.10.0`. - **AndroidX Compose:** - `androidx.compose:compose-bom` to `2025.12.00`. - `androidx.compose.material3:material3` to `1.4.0`. - **AndroidX Room:** - All `androidx.room` artifacts to `2.8.4`. - **AndroidX WorkManager:** - `androidx.work:work-runtime-ktx` to `2.11.0`. - **AndroidX Databinding:** - `androidx.databinding:databinding-runtime` to `8.13.1`. - **AndroidX DataStore:** - `androidx.datastore:datastore-preferences` to `1.2.0`. --- app/build.gradle | 44 ++++++++++++++++++++++---------------------- build.gradle | 8 ++++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 50414a0..6658919 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,8 +24,8 @@ android { applicationId "com.maary.liveinpeace" minSdk 31 targetSdk 36 - versionCode 5 - versionName "2025.09.21" + versionCode 6 + versionName "2025.12.04" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -140,42 +140,42 @@ dependencies { implementation 'com.google.android.material:material:1.13.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation 'androidx.activity:activity-ktx:1.11.0' - implementation 'androidx.databinding:databinding-runtime:8.13.0' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.4' - implementation 'androidx.activity:activity-compose:1.11.0' - implementation platform('androidx.compose:compose-bom:2025.09.00') + implementation 'androidx.activity:activity-ktx:1.12.1' + implementation 'androidx.databinding:databinding-runtime:8.13.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0' + implementation 'androidx.activity:activity-compose:1.12.1' + implementation platform('androidx.compose:compose-bom:2025.12.00') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material3:material3:1.4.0-rc01' - androidTestImplementation platform('androidx.compose:compose-bom:2025.09.00') + implementation 'androidx.compose.material3:material3:1.4.0' + androidTestImplementation platform('androidx.compose:compose-bom:2025.12.00') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' // LiveData - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.4" - implementation "androidx.lifecycle:lifecycle-common-java8:2.9.4" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4" - implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4" - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4' + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.10.0" + implementation "androidx.lifecycle:lifecycle-common-java8:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0" + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0' - implementation "com.google.dagger:hilt-android:2.57.1" - ksp "com.google.dagger:hilt-compiler:2.57.1" + implementation "com.google.dagger:hilt-android:2.57.2" + ksp "com.google.dagger:hilt-compiler:2.57.2" - implementation "androidx.room:room-runtime:2.8.0" - annotationProcessor "androidx.room:room-compiler:2.8.0" - implementation 'androidx.room:room-ktx:2.8.0' - ksp "androidx.room:room-compiler:2.8.0" + implementation "androidx.room:room-runtime:2.8.4" + annotationProcessor "androidx.room:room-compiler:2.8.4" + implementation 'androidx.room:room-ktx:2.8.4' + ksp "androidx.room:room-compiler:2.8.4" - implementation "androidx.datastore:datastore-preferences:1.2.0-alpha02" + implementation "androidx.datastore:datastore-preferences:1.2.0" implementation("com.google.accompanist:accompanist-permissions:0.37.3") implementation "androidx.compose.material:material-icons-extended:1.7.8" - implementation "androidx.work:work-runtime-ktx:2.10.4" + implementation "androidx.work:work-runtime-ktx:2.11.0" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.3.0' diff --git a/build.gradle b/build.gradle index 8ff9ca7..ff8815b 100755 --- a/build.gradle +++ b/build.gradle @@ -2,8 +2,8 @@ plugins { id 'com.android.application' version '8.11.2' apply false id 'com.android.library' version '8.11.2' apply false - id 'org.jetbrains.kotlin.android' version '2.2.20' apply false - id 'com.google.devtools.ksp' version "2.2.20-2.0.3" apply false - id 'org.jetbrains.kotlin.plugin.compose' version '2.2.20' apply false - id 'com.google.dagger.hilt.android' version '2.57.1' apply false + id 'org.jetbrains.kotlin.android' version '2.2.21' apply false + id 'com.google.devtools.ksp' version "2.2.21-2.0.4" apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.2.21' apply false + id 'com.google.dagger.hilt.android' version '2.57.2' apply false } \ No newline at end of file