Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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'
Expand Down
19 changes: 17 additions & 2 deletions app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ class ConnectionListAdapter : ListAdapter<Connection, ConnectionListAdapter.Conn

override fun onBindViewHolder(holder: ConnectionViewHolder, position: Int) {
val current = getItem(position)
holder.bind( current.name, current.type, current.duration, current.disconnectedTime)
holder.bind( current.name,
current.type,
current.duration,
current.disconnectedTime,
current.startVolume,
current.endVolume)
}

class ConnectionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val connectionIconView: ImageView = itemView.findViewById(R.id.device_icon)
private val connectionDeviceNameView: TextView = itemView.findViewById(R.id.device_name)
private val connectionDurationView: TextView = itemView.findViewById(R.id.device_connection_time)
private val connectionIndicatorView: ImageView = itemView.findViewById(R.id.connection_time_prefix)
private val connectionVolumeLogView: TextView = itemView.findViewById(R.id.text_volume_log)

fun bind(deviceName: String?, type: Int?, duration: Long?, disconnectedTime: Long?) {
fun bind(deviceName: String?, type: Int?, duration: Long?, disconnectedTime: Long?, startVol: Int? = null, endVol: Int? = null) {
connectionIconView.setImageResource(
chooseDeviceDrawable(
type = type,
Expand All @@ -56,6 +62,15 @@ class ConnectionListAdapter : ListAdapter<Connection, ConnectionListAdapter.Conn
drawableHeadphone = R.drawable.ic_headphone_round_alt,
drawableBLE = R.drawable.ic_bluetooth_round_alt))
}

if (startVol == null || endVol == null) connectionVolumeLogView.visibility = View.GONE
else {
val startVolString = startVol.toString()
val endVolString = endVol.toString()
connectionVolumeLogView.visibility = View.VISIBLE
connectionVolumeLogView.text = itemView.context.getString(R.string.volume_log_format, startVolString, endVolString)
}
Comment on lines +66 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block can be simplified. You can remove the intermediate startVolString and endVolString variables, as getString can directly accept the Int? values and will call toString() on them. This makes the code more concise and idiomatic.

            if (startVol == null || endVol == null) {
                connectionVolumeLogView.visibility = View.GONE
            } else {
                connectionVolumeLogView.visibility = View.VISIBLE
                connectionVolumeLogView.text = itemView.context.getString(R.string.volume_log_format, startVol, endVol)
            }


}

companion object {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/maary/liveinpeace/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ data class Connection(
@ColumnInfo(name = "disconnected_time") val disconnectedTime: Long?,
@ColumnInfo(name = "duration") val duration: Long?,
@ColumnInfo(name = "date") val date: String,

// 记录连接前和断开后的音量百分比 (0-100)
@ColumnInfo(name = "start_volume") val startVolume: Int? = null,
@ColumnInfo(name = "end_volume") val endVolume: Int? = null
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

@Database(entities = [Connection::class], version = 1, exportSchema = false)
@Database(entities = [Connection::class], version = 2, exportSchema = false)
abstract class ConnectionRoomDatabase : RoomDatabase() {

abstract fun connectionDao(): ConnectionDao
Expand All @@ -14,28 +16,27 @@ abstract class ConnectionRoomDatabase : RoomDatabase() {
@Volatile
private var INSTANCE: ConnectionRoomDatabase? = null

// 2. 定义从版本 1 到 2 的迁移策略
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// SQLite 的 ALTER TABLE 命令一次只能添加一列,所以需要两条语句
// 注意:这里的数据类型 INTEGER 对应 Kotlin 的 Int? (Nullable)
// 如果你的实体中定义的是非空 Int,这里可能需要指定 DEFAULT 0
db.execSQL("ALTER TABLE connection_table ADD COLUMN start_volume INTEGER")
db.execSQL("ALTER TABLE connection_table ADD COLUMN end_volume INTEGER")
}
}

fun getDatabase(context: Context): ConnectionRoomDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
ConnectionRoomDatabase::class.java,
"connection_database"
).build()
context.applicationContext,
ConnectionRoomDatabase::class.java,
"connection_database"
).addMigrations(MIGRATION_1_2).build()
INSTANCE = instance
instance
}
}
}
}

//class Converters {
// @TypeConverter
// fun fromTimestamp(value: Long?): Date? {
// return value?.let { Date(it) }
// }
//
// @TypeConverter
// fun dateToTimestamp(date: Date?): Long? {
// return date?.time?.toLong()
// }
//}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> {
Expand Down Expand Up @@ -126,4 +128,26 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont
pref[PREF_EAR_PROTECTION_THRESHOLD_MAX] = range.last
}
}

// 获取设置状态
fun isVolumeRestoreEnabled(): Flow<Boolean> {
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<Int> = datastore.data
.map { preferences -> preferences[PREF_LAST_SYSTEM_VOLUME] ?: -1 }
Comment on lines +144 to +145

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function uses -1 as a default value to indicate that no volume has been saved. While functional, this "magic number" can make the code harder to understand for others who might not know its special meaning. It's a best practice to define such special values as named constants.

I recommend adding a constant like const val VOLUME_NOT_SET = -1 to your Constants.kt file and using it here and in ForegroundService where you check if (savedVolume != -1). This will make the code more self-documenting and maintainable.

Suggested change
fun getLastSystemVolume(): Flow<Int> = datastore.data
.map { preferences -> preferences[PREF_LAST_SYSTEM_VOLUME] ?: -1 }
fun getLastSystemVolume(): Flow<Int> = datastore.data
.map { preferences -> preferences[PREF_LAST_SYSTEM_VOLUME] ?: Constants.VOLUME_NOT_SET }


// 更新系统音量记录
suspend fun saveLastSystemVolume(volume: Int) {
datastore.edit { preferences ->
preferences[PREF_LAST_SYSTEM_VOLUME] = volume
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -324,14 +327,19 @@ 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,
type = deviceInfo.type,
connectedTime = System.currentTimeMillis(),
disconnectedTime = null,
duration = null,
date = LocalDate.now().toString()
date = LocalDate.now().toString(),
startVolume = startVol,
endVolume = null
)
wasAdded = true

Expand Down Expand Up @@ -365,13 +373,54 @@ 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)
}
}
}
}

// [第二步] 在锁释放后:等待系统切换、读取音量、恢复音量
// 这样做可以避免阻塞其他设备的连接/断开处理
if (connectionToSave != null) {
// [核心修改] 延迟 600ms,等待系统将音频输出切换回扬声器并更新音量状态
// 具体的毫秒数可能因机型而异,500-800ms 通常是安全的
delay(600)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a fixed delay(600) is a good pragmatic approach for this experimental feature, but it can be fragile. On some devices or under heavy load, this delay might not be sufficient, leading to an incorrect endVolume being recorded. On faster devices, it adds an unnecessary wait.

For future improvement, you might consider a more robust mechanism. For example, you could listen for the AudioManager.ACTION_AUDIO_BECOMING_NOISY broadcast intent, which signals that the audio output is about to change. This would provide a more reliable trigger than a fixed delay. For now, the current implementation is a reasonable starting point.


// 此时获取的音量应该是系统切换后的音量(即“断开后”的真实状态)
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The statement return connectionToSave on this line is slightly confusing. If the if (connectionToSave != null) block above is entered, finalConnection is returned and this line is unreachable. If the if block is skipped, connectionToSave is null at this point.

To improve code clarity and explicitly show that null is returned in that case, it's better to return null directly.

Suggested change
return connectionToSave
return null

}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ class SettingsViewModel @Inject constructor(
}
}

val isVolumeRestoreEnabled: StateFlow<Boolean> = preferenceRepository.isVolumeRestoreEnabled()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)

fun toggleVolumeRestore(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.setVolumeRestoreEnabled(enabled)
}
}

init {
checkAndSyncServiceState()
}
Expand Down
Loading
Loading