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/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 { @@ -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/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 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() } diff --git a/app/src/main/res/layout/item_connection.xml b/app/src/main/res/layout/item_connection.xml index 8bdc59d..cdf2824 100644 --- a/app/src/main/res/layout/item_connection.xml +++ b/app/src/main/res/layout/item_connection.xml @@ -55,4 +55,17 @@ android:textSize="12sp" tools:text="3hrs"/> + + + \ No newline at end of file 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 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