From 4fe776d7a26a520bd65ab4b9de801c34e41280d8 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:04:28 +0800 Subject: [PATCH 01/48] optimize code and project structure --- app/build.gradle | 19 +- .../java/com/maary/liveinpeace/Constants.kt | 6 + .../com/maary/liveinpeace/HistoryActivity.kt | 182 +++--- .../maary/liveinpeace/database/Connection.kt | 6 +- .../liveinpeace/service/ForegroundService.kt | 520 +++++++++++------- .../liveinpeace/service/QSTileService.kt | 71 ++- build.gradle | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 489 insertions(+), 321 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 715bca2..3058c2a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.jetbrains.kotlin.android' id 'kotlin-android' id 'com.google.devtools.ksp' + id 'kotlin-parcelize' } def keystorePropertiesFile = rootProject.file("key.properties") @@ -12,7 +13,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) android { namespace 'com.maary.liveinpeace' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "com.maary.liveinpeace" @@ -85,16 +86,16 @@ android { dependencies { - implementation 'androidx.core:core-ktx:1.13.1' - implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' - implementation 'androidx.activity:activity-ktx:1.9.0' - implementation 'androidx.databinding:databinding-runtime:8.4.1' + implementation 'androidx.activity:activity-ktx:1.10.1' + implementation 'androidx.databinding:databinding-runtime:8.9.1' - def lifecycle_version = '2.8.0' + def lifecycle_version = '2.8.7' // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" @@ -111,6 +112,6 @@ dependencies { ksp "androidx.room:room-compiler:$room_version" testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index 0a4b6b8..589e092 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -16,6 +16,8 @@ class Constants { const val PREF_WATCHING_CONNECTING_TIME = "watching_connecting" const val PREF_ENABLE_EAR_PROTECTION = "ear_protection_enabled" const val PREF_WELCOME_FINISHED = "welcome_finished" + // SharedPreferences key for service running state + const val PREF_SERVICE_RUNNING = "service_running_state" // 设置通知 id const val ID_NOTIFICATION_SETTINGS = 3 // 前台通知 id @@ -47,6 +49,10 @@ class Constants { // 前台服务状态改变广播 const val BROADCAST_ACTION_FOREGROUND = "com.maary.liveinpeace.ACTION_FOREGROUND_SERVICE_STATE" const val BROADCAST_FOREGROUND_INTENT_EXTRA = "isForegroundServiceRunning" + // Broadcast action for connection list updates + const val BROADCAST_ACTION_CONNECTIONS_UPDATE = "com.maary.liveinpeace.CONNECTIONS_UPDATE" + const val EXTRA_CONNECTIONS_LIST = "com.maary.liveinpeace.extra.CONNECTIONS_LIST" + // 当音量操作动作太过频繁后等待时间 const val REQUESTING_WAIT_MILLIS = 500 // 不同通知频道 ID diff --git a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt index 19043be..c1d45e0 100644 --- a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt @@ -1,60 +1,82 @@ package com.maary.liveinpeace +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build import android.os.Bundle +import android.util.Log import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat // For receiver registration import androidx.core.view.WindowCompat import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.MaterialDatePicker +import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE // Import new constant +import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST // Import new constant import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_BUTTON import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_DATABASE import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.databinding.ActivityHistoryBinding -import com.maary.liveinpeace.service.ForegroundService +// import com.maary.liveinpeace.service.ForegroundService // No longer needed for static access import java.text.SimpleDateFormat import java.time.LocalDate +import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Locale +import java.util.concurrent.TimeUnit // Keep TimeUnit for duration calculation -class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { +// Remove DeviceMapChangeListener from the class declaration +class HistoryActivity : AppCompatActivity() { private lateinit var binding: ActivityHistoryBinding private val connectionViewModel: ConnectionViewModel by viewModels { ConnectionViewModelFactory((application as ConnectionsApplication).repository) } + // Adapter for currently connected devices private val currentAdapter = ConnectionListAdapter() + // Adapter for historical connections (from ViewModel) + private val historyAdapter = ConnectionListAdapter() // Use a separate adapter instance + + // Declare the BroadcastReceiver + private var connectionsUpdateReceiver: BroadcastReceiver? = null override fun onResume() { super.onResume() - ForegroundService.addDeviceMapChangeListener(this) + // Register the receiver + registerConnectionsUpdateReceiver() + // Remove old listener registration + // ForegroundService.addDeviceMapChangeListener(this) } override fun onPause() { super.onPause() - ForegroundService.removeDeviceMapChangeListener(this) + // Unregister the receiver + unregisterReceiver(connectionsUpdateReceiver) + connectionsUpdateReceiver = null // Allow garbage collection + // Remove old listener removal + // ForegroundService.removeDeviceMapChangeListener(this) } - private fun currentConnectionsDuration(currentList: MutableList) : MutableList{ + // Calculates duration for currently connected items based on their connect time + private fun calculateCurrentConnectionsDuration(currentList: List): List { val now = System.currentTimeMillis() - - for ( (index, connection) in currentList.withIndex()){ - val connectedTime = connection.connectedTime - val duration = now - connectedTime!! - currentList[index] = Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, - disconnectedTime = null, - duration = duration, - date = connection.date - ) + return currentList.map { connection -> + if (connection.connectedTime != null && connection.disconnectedTime == null) { + val duration = now - connection.connectedTime + // Create a new Connection object with updated duration + // Ensure other fields are copied correctly. Using copy() is ideal. + connection.copy(duration = duration) // Use copy for data classes + } else { + // If already disconnected or no connectedTime, return as is + connection + } } - - return currentList } override fun onCreate(savedInstanceState: Bundle?) { @@ -63,11 +85,12 @@ class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { binding = ActivityHistoryBinding.inflate(layoutInflater) setContentView(binding.root) - var pickedDate : String = LocalDate.now().toString() - - val connectionAdapter = ConnectionListAdapter() + // Use DateTimeFormatter for LocalDate + // Define the formatter using the pattern from Constants + val dbDateFormatter = DateTimeFormatter.ofPattern(Constants.Companion.PATTERN_DATE_DATABASE, Locale.getDefault()) + var pickedDate: String = LocalDate.now().format(dbDateFormatter) // Use the correct formatter - // Makes only dates from today forward selectable. + // Makes only dates from today backward selectable. val constraintsBuilder = CalendarConstraints.Builder() .setValidator(DateValidatorPointBackward.now()) @@ -79,30 +102,32 @@ class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) .build() - fun updateHistoryList(checkedId: Int){ - if (checkedId == R.id.button_timeline) { - connectionViewModel.getAllConnectionsOnDate(pickedDate).observe(this) { connections -> - connections.let { connectionAdapter.submitList(it) } - } + // Function to update the historical list based on ViewModel + fun updateHistoryList(checkedId: Int) { + val listToObserve = if (checkedId == R.id.button_timeline) { + connectionViewModel.getAllConnectionsOnDate(pickedDate) + } else { // R.id.button_summary + connectionViewModel.getSummaryOnDate(pickedDate) } - if (checkedId == R.id.button_summary) { - connectionViewModel.getSummaryOnDate(pickedDate).observe(this) { connections -> - connections.let { connectionAdapter.submitList(it) } - } + listToObserve.observe(this) { connections -> + connections?.let { historyAdapter.submitList(it) } } - updateCurrentAdapter() + // Note: updateCurrentAdapter() is now called by the broadcast receiver, + // so it's removed from here unless you need to clear it on date change. } - fun changeDate(dateInMilli: Long?){ - if (dateInMilli == null) return changeDate(System.currentTimeMillis()) - binding.buttonCalendar.text = formatMillisecondsToDate(dateInMilli, PATTERN_DATE_BUTTON) - pickedDate = formatMillisecondsToDate(dateInMilli, PATTERN_DATE_DATABASE) + fun changeDate(dateInMilli: Long?) { + val effectiveDateInMillis = dateInMilli ?: System.currentTimeMillis() // Use current time if null + binding.buttonCalendar.text = formatMillisecondsToDate(effectiveDateInMillis, PATTERN_DATE_BUTTON) + pickedDate = formatMillisecondsToDate(effectiveDateInMillis, PATTERN_DATE_DATABASE) updateHistoryList(binding.toggleHistory.checkedButtonId) - updateCurrentAdapter() + // Optionally clear the current list when date changes, or let the broadcast handle it + // updateCurrentConnectionsView(emptyList()) // Example: Clear current list } + // Setup historical RecyclerView binding.historyList.isNestedScrollingEnabled = false - binding.historyList.adapter = connectionAdapter + binding.historyList.adapter = historyAdapter binding.historyList.layoutManager = LinearLayoutManager(this) binding.toggleHistory.check(R.id.button_timeline) @@ -117,8 +142,9 @@ class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { datePicker.show(supportFragmentManager, "MATERIAL_DATE_PICKER") } - binding.buttonCalendar.setOnLongClickListener{ + binding.buttonCalendar.setOnLongClickListener { changeDate(System.currentTimeMillis()) + // Rebuild date picker if needed, or just reset selection datePicker = MaterialDatePicker.Builder.datePicker() .setTitleText(R.string.select_date) .setCalendarConstraints(constraintsBuilder.build()) @@ -127,50 +153,72 @@ class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { true } + // Setup current connections RecyclerView binding.currentList.isNestedScrollingEnabled = false binding.currentList.adapter = currentAdapter binding.currentList.layoutManager = LinearLayoutManager(this) - updateCurrentAdapter() + // Initial state for the current list view + updateCurrentConnectionsView(emptyList()) // Start with empty, wait for broadcast - connectionViewModel.getAllConnectionsOnDate(pickedDate).observe(this) { connections -> - connections.let { connectionAdapter.submitList(it) } - } + // Initial load for history list + updateHistoryList(binding.toggleHistory.checkedButtonId) + // Listener for history view toggle (Timeline vs Summary) binding.toggleHistory.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (!isChecked) return@addOnButtonCheckedListener - updateHistoryList(checkedId) - } - - datePicker.addOnPositiveButtonClickListener { - changeDate(datePicker.selection) + if (isChecked) { // Only react when a button becomes checked + updateHistoryList(checkedId) + } } - } - private fun updateCurrentAdapter(){ - currentAdapter.submitList(currentConnectionsDuration(ForegroundService.getConnections())) - if (currentAdapter.itemCount == 0){ - binding.titleCurrent.visibility = View.GONE - }else{ - binding.titleCurrent.visibility = View.VISIBLE + // Listener for Date Picker confirmation + datePicker.addOnPositiveButtonClickListener { selection -> + changeDate(selection) // selection should be Long? } } - override fun onDeviceMapChanged(deviceMap: Map) { - if (deviceMap.isEmpty()){ - binding.titleCurrent.visibility = View.GONE - }else{ - binding.titleCurrent.visibility = View.VISIBLE - } - currentAdapter.submitList(currentConnectionsDuration(deviceMap.values.toMutableList())) + // Renamed and modified function to update the 'current' list RecyclerView + private fun updateCurrentConnectionsView(connections: List) { + val processedList = calculateCurrentConnectionsDuration(connections) + currentAdapter.submitList(processedList) + // Control visibility of the "Currently Connected" title + binding.titleCurrent.visibility = if (processedList.isEmpty()) View.GONE else View.VISIBLE } private fun formatMillisecondsToDate(milliseconds: Long?, pattern: String): String { + // Default to now if milliseconds is null + val millis = milliseconds ?: System.currentTimeMillis() val dateFormat = SimpleDateFormat(pattern, Locale.getDefault()) val calendar = Calendar.getInstance() - if (milliseconds != null) { - calendar.timeInMillis = milliseconds - } + calendar.timeInMillis = millis return dateFormat.format(calendar.time) } + + // --- BroadcastReceiver Implementation --- + + private fun registerConnectionsUpdateReceiver() { + if (connectionsUpdateReceiver == null) { + connectionsUpdateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == BROADCAST_ACTION_CONNECTIONS_UPDATE) { + val connectionsList: ArrayList? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(EXTRA_CONNECTIONS_LIST, Connection::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(EXTRA_CONNECTIONS_LIST) + } + + Log.d("HistoryActivity", "Received connection update: ${connectionsList?.size ?: 0} items") + // Update the UI with the received list, handle null case + updateCurrentConnectionsView(connectionsList ?: emptyList()) + } + } + } + val filter = IntentFilter(BROADCAST_ACTION_CONNECTIONS_UPDATE) + // Use ContextCompat for compatibility and specifying receiver export behavior + ContextCompat.registerReceiver(this, connectionsUpdateReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + Log.d("HistoryActivity", "ConnectionsUpdateReceiver registered") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/database/Connection.kt b/app/src/main/java/com/maary/liveinpeace/database/Connection.kt index e2587cb..9afcd0b 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/Connection.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/Connection.kt @@ -1,10 +1,13 @@ package com.maary.liveinpeace.database +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize import java.sql.Date +@Parcelize @Entity(tableName = "connection_table") data class Connection( @PrimaryKey(autoGenerate = true) val id: Int = 0, @@ -14,5 +17,4 @@ data class Connection( @ColumnInfo(name = "disconnected_time") val disconnectedTime: Long?, @ColumnInfo(name = "duration") val duration: Long?, @ColumnInfo(name = "date") val date: String, -// @ColumnInfo(name = "volume_changes") val volumeChanges: String - ) + ) : Parcelable 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 d3bf2c2..01e5dc9 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -1,5 +1,6 @@ package com.maary.liveinpeace.service +import android.Manifest import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationManager @@ -8,6 +9,8 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.SharedPreferences // Import SharedPreferences +import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager @@ -15,19 +18,24 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit // Import SharedPreferences edit extension import androidx.core.graphics.drawable.IconCompat +import com.maary.liveinpeace.Constants // Keep using Constants import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS import com.maary.liveinpeace.Constants.Companion.ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT import com.maary.liveinpeace.Constants.Companion.ALERT_TIME import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_FOREGROUND +import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE // New Action import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_MUTE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_TOGGLE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_UPDATE import com.maary.liveinpeace.Constants.Companion.BROADCAST_FOREGROUND_INTENT_EXTRA import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT +import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST // New Extra Key import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_ALERT import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_FOREGROUND import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_FORE @@ -37,9 +45,9 @@ import com.maary.liveinpeace.Constants.Companion.MODE_IMG import com.maary.liveinpeace.Constants.Companion.MODE_NUM import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION import com.maary.liveinpeace.Constants.Companion.PREF_ICON +import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING // New Pref Key import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME import com.maary.liveinpeace.Constants.Companion.SHARED_PREF -import com.maary.liveinpeace.DeviceMapChangeListener import com.maary.liveinpeace.DeviceTimer import com.maary.liveinpeace.R import com.maary.liveinpeace.SleepNotification.find @@ -52,17 +60,26 @@ import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel // Import cancel import kotlinx.coroutines.launch import java.text.DateFormat import java.time.LocalDate import java.util.Date +import java.util.concurrent.ConcurrentHashMap // Use ConcurrentHashMap for thread safety if needed, though access seems main-thread confined -class ForegroundService: Service() { +class ForegroundService : Service() { + + // Use instance scope for CoroutineScope to easily cancel jobs in onDestroy + private val serviceScope = CoroutineScope(Dispatchers.IO) private lateinit var database: ConnectionRoomDatabase private lateinit var connectionDao: ConnectionDao + private lateinit var audioManager: AudioManager + private lateinit var sharedPreferences: SharedPreferences // Instance variable for SharedPreferences - private val deviceTimerMap: MutableMap = mutableMapOf() + // Instance variable for device map + private val deviceMap: MutableMap = ConcurrentHashMap() // Use ConcurrentHashMap if worried about potential multi-threaded access, otherwise regular HashMap is fine. + private val deviceTimerMap: MutableMap = ConcurrentHashMap() private val volumeDrawableIds = intArrayOf( R.drawable.ic_volume_silent, @@ -74,45 +91,23 @@ class ForegroundService: Service() { private lateinit var volumeComment: Array - private lateinit var audioManager: AudioManager - companion object { - private var isForegroundServiceRunning = false - @JvmStatic - fun isForegroundServiceRunning(): Boolean { - return isForegroundServiceRunning - } - - private val deviceMap: MutableMap = mutableMapOf() - - // 在伴生对象中定义一个静态方法,用于其他类访问deviceMap - fun getConnections(): MutableList { - return deviceMap.values.toMutableList() - } - - private val deviceMapChangeListeners: MutableList = mutableListOf() - - fun addDeviceMapChangeListener(listener: DeviceMapChangeListener) { - deviceMapChangeListeners.add(listener) - } - - fun removeDeviceMapChangeListener(listener: DeviceMapChangeListener) { - deviceMapChangeListeners.remove(listener) - } + // Method to broadcast the current connection list + private fun broadcastConnectionsUpdate() { + val intent = Intent(BROADCAST_ACTION_CONNECTIONS_UPDATE) + // Convert map values to ArrayList which is Serializable/Parcelable + val connectionList = ArrayList(deviceMap.values) + intent.putParcelableArrayListExtra(EXTRA_CONNECTIONS_LIST, connectionList) + sendBroadcast(intent) } - private fun notifyDeviceMapChange() { - deviceMapChangeListeners.forEach { listener -> - listener.onDeviceMapChanged(deviceMap) - } - } private fun getVolumePercentage(context: Context): Int { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - return 100 * currentVolume / maxVolume + // Avoid division by zero if maxVolume is 0 + return if (maxVolume > 0) 100 * currentVolume / maxVolume else 0 } private fun getVolumeLevel(percent: Int): Int { @@ -144,29 +139,37 @@ class ForegroundService: Service() { } } - private fun saveDataWhenStop(){ + // Saves data for currently connected devices when service stops + private fun saveDataForActiveConnections() { val disconnectedTime = System.currentTimeMillis() + val currentConnections = deviceMap.toMap() // Create a copy to avoid ConcurrentModificationException if needed + deviceMap.clear() // Clear the instance map - for ( (deviceName, connection) in deviceMap){ - + currentConnections.forEach { (_, connection) -> val connectedTime = connection.connectedTime - val connectionTime = disconnectedTime - connectedTime!! - - CoroutineScope(Dispatchers.IO).launch { - connectionDao.insert( - Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, - disconnectedTime = disconnectedTime, - duration = connectionTime, - date = connection.date - ) - ) + if (connectedTime != null) { + val connectionTime = disconnectedTime - connectedTime + serviceScope.launch { + try { + connectionDao.insert( + Connection( + name = connection.name, + type = connection.type, + connectedTime = connection.connectedTime, + disconnectedTime = disconnectedTime, + duration = connectionTime, + date = connection.date + ) + ) + Log.d("ForegroundService", "Saved connection data for ${connection.name}") + } catch (e: Exception) { + Log.e("ForegroundService", "Error saving connection data for ${connection.name}", e) + } + } } - deviceMap.remove(deviceName) } - return + // Notify that the connection list is now empty + broadcastConnectionsUpdate() } private val audioDeviceCallback = object : AudioDeviceCallback() { @@ -174,9 +177,8 @@ class ForegroundService: Service() { override fun onAudioDevicesAdded(addedDevices: Array?) { val connectedTime = System.currentTimeMillis() - val sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) - // 在设备连接时记录设备信息和接入时间 + var deviceAdded = false addedDevices?.forEach { deviceInfo -> if (deviceInfo.type in listOf( AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, @@ -186,216 +188,321 @@ class ForegroundService: Service() { AudioDeviceInfo.TYPE_FM_TUNER, AudioDeviceInfo.TYPE_REMOTE_SUBMIX, AudioDeviceInfo.TYPE_TELEPHONY, - 28, + 28, // Consider defining this constant if it's meaningful ) ) { return@forEach } - val deviceName = deviceInfo.productName.toString().trim() - if (deviceName == Build.MODEL) return@forEach - Log.v("MUTE_DEVICE", deviceName) - Log.v("MUTE_TYPE", deviceInfo.type.toString()) - deviceMap[deviceName] = Connection( - id=1, - name = deviceInfo.productName.toString(), - type = deviceInfo.type, - connectedTime = connectedTime, - disconnectedTime = null, - duration = null, - date = LocalDate.now().toString() - ) - notifyDeviceMapChange() - if (sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false)){ - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - var boolProtected = false - while (getVolumePercentage(applicationContext)>25) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) - } - while (getVolumePercentage(applicationContext)<10) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) + + val deviceName = deviceInfo.productName?.toString()?.trim() ?: "Unknown Device ${deviceInfo.id}" // Handle null productName + if (deviceName == Build.MODEL || deviceName.isBlank()) return@forEach // Skip self or blank names + + Log.v("MUTE_DEVICE", "Device Added: $deviceName (Type: ${deviceInfo.type})") + + // Add to instance map + if (!deviceMap.containsKey(deviceName)) { + deviceMap[deviceName] = Connection( + name = deviceName, + type = deviceInfo.type, + connectedTime = connectedTime, + disconnectedTime = null, + duration = null, + date = LocalDate.now().toString() + ) + deviceAdded = true + + // Ear Protection Logic + if (sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false)) { + var boolProtected = false + // Use loop with counter or check to prevent infinite loop if volume doesn't change + var attempts = 100 // Limit attempts + while (getVolumePercentage(applicationContext) > 25 && attempts-- > 0) { + boolProtected = true + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) + } + attempts = 100 // Reset attempts + while (getVolumePercentage(applicationContext) < 10 && attempts-- > 0) { + boolProtected = true + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) + } + if (boolProtected) { + Log.d("ForegroundService", "Ear protection applied for $deviceName") + NotificationManagerCompat.from(applicationContext).apply { + notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + } + } } - if (boolProtected){ - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + + // Start timer only if watching is enabled + if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)) { + if (!deviceTimerMap.containsKey(deviceName)) { + val deviceTimer = DeviceTimer(context = applicationContext, deviceName = deviceName) + Log.v("MUTE_DEVICEMAP", "Starting timer for $deviceName") + deviceTimer.start() + deviceTimerMap[deviceName] = deviceTimer } } } - // 执行其他逻辑,比如将设备信息保存到数据库或日志中 } - if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)){ - for ((productName, _) in deviceMap){ - if (deviceTimerMap.containsKey(productName)) continue - val deviceTimer = DeviceTimer(context = applicationContext, deviceName = productName) - Log.v("MUTE_DEVICEMAP", productName) - deviceTimer.start() - deviceTimerMap[productName] = deviceTimer + if (deviceAdded) { + Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") + // Broadcast the updated list + broadcastConnectionsUpdate() + // Update the foreground notification + NotificationManagerCompat.from(applicationContext).apply { + notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) } } - - Log.v("MUTE_MAP", deviceMap.toString()) - - // Handle newly added audio devices - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } } @SuppressLint("MissingPermission") override fun onAudioDevicesRemoved(removedDevices: Array?) { + var deviceRemoved = false - // 在设备连接时记录设备信息和接入时间 removedDevices?.forEach { deviceInfo -> - val deviceName = deviceInfo.productName.toString() + val deviceName = deviceInfo.productName?.toString()?.trim() ?: return@forEach // Skip if no name val disconnectedTime = System.currentTimeMillis() - if (deviceMap.containsKey(deviceName)){ + if (deviceMap.containsKey(deviceName)) { + deviceRemoved = true + val connection = deviceMap.remove(deviceName) // Remove from instance map - val connectedTime = deviceMap[deviceName]?.connectedTime - val connectionTime = disconnectedTime - connectedTime!! + Log.v("MUTE_DEVICE", "Device Removed: $deviceName (Type: ${deviceInfo.type})") - if (connectionTime > ALERT_TIME){ - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_ALERT) - } + if (connection?.connectedTime != null) { + val connectionTime = disconnectedTime - connection.connectedTime + + // Cancel alert notification if connection was long + if (connectionTime > ALERT_TIME) { + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(ID_NOTIFICATION_ALERT) + } - val baseConnection = deviceMap[deviceName] - CoroutineScope(Dispatchers.IO).launch { - if (baseConnection != null) { - connectionDao.insert( - Connection( - name = baseConnection.name, - type = baseConnection.type, - connectedTime = baseConnection.connectedTime, - disconnectedTime = disconnectedTime, - duration = connectionTime, - date = baseConnection.date + // Save data using instance scope + serviceScope.launch { + try { + connectionDao.insert( + Connection( + name = connection.name, + type = connection.type, + connectedTime = connection.connectedTime, + disconnectedTime = disconnectedTime, + duration = connectionTime, + date = connection.date ) - ) + ) + Log.d("ForegroundService", "Saved connection data for removed device ${connection.name}") + } catch (e: Exception) { + Log.e("ForegroundService", "Error saving connection data for removed device ${connection.name}", e) + } } } - deviceMap.remove(deviceName) - notifyDeviceMapChange() + // Stop and remove timer if watching is enabled + if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)) { + deviceTimerMap.remove(deviceName)?.let { + Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") + it.stop() + } + } } - val sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) - if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)){ - if (deviceTimerMap.containsKey(deviceName)){ - deviceTimerMap[deviceName]?.stop() - deviceTimerMap.remove(deviceName) + if (deviceRemoved) { + Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") + // Broadcast the updated list + broadcastConnectionsUpdate() + // Update the foreground notification + NotificationManagerCompat.from(applicationContext).apply { + notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) } + deviceRemoved = false } - // 执行其他逻辑,比如将设备信息保存到数据库或日志中 } - // Handle removed audio devices - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } + } } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate() { super.onCreate() - Log.v("MUTE_TEST", "ON_CREATE") + // Initialize SharedPreferences + sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) // Consider using a Handler for the callback thread if needed - // 注册音量变化广播接收器 - val filter = IntentFilter().apply { + // Initialize Database and DAO + database = ConnectionRoomDatabase.getDatabase(applicationContext) + connectionDao = database.connectionDao() + + // Load comments + volumeComment = resources.getStringArray(R.array.array_volume_comment) + + // Register Receivers + val volumeFilter = IntentFilter().apply { addAction("android.media.VOLUME_CHANGED_ACTION") - } - registerReceiver(volumeChangeReceiver, filter) + } // Use constant + // Check for permission before registering if targeting Android 14+ for non-exported receivers needing permissions + registerReceiver(volumeChangeReceiver, volumeFilter) - val sleepFilter = IntentFilter().apply { - addAction(BROADCAST_ACTION_SLEEPTIMER_UPDATE) - } + val sleepFilter = IntentFilter(BROADCAST_ACTION_SLEEPTIMER_UPDATE) + // Use RECEIVER_NOT_EXPORTED for internal broadcasts registerReceiver(sleepReceiver, sleepFilter, RECEIVER_NOT_EXPORTED) - database = ConnectionRoomDatabase.getDatabase(applicationContext) - connectionDao = database.connectionDao() - startForeground(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(context = applicationContext)) - notifyForegroundServiceState(true) - Log.v("MUTE_TEST", "ON_CREATE_FINISH") + // Start foreground + startForeground(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + + // Update persisted state and notify components + setServiceRunningState(true) + + Log.d("ForegroundService", "onCreate Finished") } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - Log.v("MUTE_TEST", "ON_START_COMMAND") - // 返回 START_STICKY,以确保 Service 在被终止后能够自动重启 + Log.d("ForegroundService", "onStartCommand received") + + // Ensure the foreground notification is up-to-date if started again + NotificationManagerCompat.from(applicationContext).apply { + if (ActivityCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + } + } return START_STICKY } override fun onDestroy() { - notifyForegroundServiceState(false) - - Log.v("MUTE_TEST", "ON_DESTROY") - - saveDataWhenStop() - // 取消注册音量变化广播接收器 - unregisterReceiver(volumeChangeReceiver) - unregisterReceiver(sleepReceiver) - audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_FOREGROUND) - Log.v("MUTE_TEST", "ON_DESTROY_FINISH") + Log.d("ForegroundService", "onDestroy - Cleaning up...") + + // Set persisted state to false *before* cleanup, in case cleanup fails + setServiceRunningState(false) + + // Save data for any remaining active connections + saveDataForActiveConnections() + + // Cancel all coroutines started by this service instance + serviceScope.cancel() + + // Stop and clear all timers + try { + deviceTimerMap.values.forEach { it.stop() } + deviceTimerMap.clear() + Log.d("ForegroundService", "Device timers stopped and cleared.") + } catch (e: Exception){ + Log.e("ForegroundService", "Error stopping timers", e) + } + + + // Unregister receivers and callbacks + try { + unregisterReceiver(volumeChangeReceiver) + Log.d("ForegroundService", "Volume receiver unregistered.") + } catch (e: IllegalArgumentException) { + Log.w("ForegroundService", "Volume receiver already unregistered?", e) + } + try { + unregisterReceiver(sleepReceiver) + Log.d("ForegroundService", "Sleep receiver unregistered.") + } catch (e: IllegalArgumentException) { + Log.w("ForegroundService", "Sleep receiver already unregistered?", e) + } + try { + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + Log.d("ForegroundService", "Audio device callback unregistered.") + } catch (e: Exception) { + Log.e("ForegroundService", "Error unregistering audio callback", e) + } + + // Stop foreground service removal notification + stopForeground(STOP_FOREGROUND_REMOVE) + + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(ID_NOTIFICATION_FOREGROUND) + Log.d("ForegroundService", "onDestroy Finished") super.onDestroy() } - override fun onBind(p0: Intent?): IBinder? { - TODO("Not yet implemented") + + // Required method for Service, return null for non-bound service + override fun onBind(intent: Intent?): IBinder? { + return null } - private fun notifyForegroundServiceState(isRunning: Boolean) { - isForegroundServiceRunning = isRunning + // Helper to update persisted state and send broadcast + private fun setServiceRunningState(isRunning: Boolean) { + // Update SharedPreferences + sharedPreferences.edit { + putBoolean(PREF_SERVICE_RUNNING, isRunning) + } + // Send broadcast to notify components like QSTileService val intent = Intent(BROADCAST_ACTION_FOREGROUND) intent.putExtra(BROADCAST_FOREGROUND_INTENT_EXTRA, isRunning) sendBroadcast(intent) + Log.d("ForegroundService", "Service running state set to $isRunning and broadcast sent.") } - @SuppressLint("LaunchActivityFromNotification") + + @SuppressLint("LaunchActivityFromNotification") // If PendingIntent launches Activity fun createForegroundNotification(context: Context): Notification { val currentVolume = getVolumePercentage(context) val currentVolumeLevel = getVolumeLevel(currentVolume) - volumeComment = resources.getStringArray(R.array.array_volume_comment) - val nIcon = generateNotificationIcon(context, - getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE).getInt(PREF_ICON, MODE_IMG)) + // Ensure volumeComment is initialized + val comment = if (::volumeComment.isInitialized && currentVolumeLevel < volumeComment.size) { + volumeComment[currentVolumeLevel] + } else { + "Volume" // Fallback + } + val iconMode = sharedPreferences.getInt(PREF_ICON, MODE_IMG) + val nIcon = generateNotificationIcon(context, iconMode) + // --- Intents for Actions --- val settingsIntent = Intent(this, SettingsReceiver::class.java).apply { action = ACTION_NAME_SETTINGS } - val snoozePendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE) + val settingsPendingIntent: PendingIntent = + PendingIntent.getBroadcast(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Use UPDATE_CURRENT if intent extras change - val actionSettings : NotificationCompat.Action = NotificationCompat.Action.Builder( - R.drawable.ic_baseline_settings_24, - resources.getString(R.string.settings), - snoozePendingIntent - ).build() + val protectionIntent = Intent(this, SettingsReceiver::class.java).apply { + action = ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT + } + val protectionPendingIntent: PendingIntent = + PendingIntent.getBroadcast(this, 1, protectionIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (1) - val sharedPreferences = getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) + val sleepIntent = Intent(context, MuteMediaReceiver::class.java).apply { + action = BROADCAST_ACTION_SLEEPTIMER_TOGGLE + } + val pendingSleepIntent = PendingIntent.getBroadcast(context, 2, sleepIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (2) - var protectionActionTitle = R.string.protection - if (sharedPreferences != null){ - if (sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false)){ - protectionActionTitle = R.string.dont_protect - } + val muteMediaIntent = Intent(context, MuteMediaReceiver::class.java).apply { + action = BROADCAST_ACTION_MUTE } + val pendingMuteIntent = PendingIntent.getBroadcast(context, 3, muteMediaIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (3) - val protectionIntent = Intent(this, SettingsReceiver::class.java).apply { - action = ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT + + // --- Determine Action Titles --- + val protectionEnabled = sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false) + val protectionActionTitle = if (protectionEnabled) R.string.dont_protect else R.string.protection + + val sleepNotification = find() // From SleepNotification object + val sleepTitle = if (sleepNotification != null) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(sleepNotification.`when`)) + } else { + resources.getString(R.string.sleep) } - val protectionPendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, protectionIntent, PendingIntent.FLAG_IMMUTABLE) + + // --- Build Actions --- + val actionSettings : NotificationCompat.Action = NotificationCompat.Action.Builder( + R.drawable.ic_baseline_settings_24, + resources.getString(R.string.settings), + settingsPendingIntent + ).build() val actionProtection : NotificationCompat.Action = NotificationCompat.Action.Builder( R.drawable.ic_headphones_protection, @@ -403,31 +510,20 @@ class ForegroundService: Service() { protectionPendingIntent ).build() - val sleepIntent = Intent(context, MuteMediaReceiver::class.java) - sleepIntent.action = BROADCAST_ACTION_SLEEPTIMER_TOGGLE - val pendingSleepIntent = PendingIntent.getBroadcast(context, 0, sleepIntent, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - val sleepNotification = find() - var sleepTitle = resources.getString(R.string.sleep) - if (sleepNotification != null ){ - sleepTitle = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(sleepNotification.`when`)) - } val actionSleepTimer: NotificationCompat.Action = NotificationCompat.Action.Builder ( - R.drawable.ic_tile, + R.drawable.ic_tile, // Consider a sleep-specific icon sleepTitle, pendingSleepIntent ).build() - val muteMediaIntent = Intent(context, MuteMediaReceiver::class.java) - muteMediaIntent.action = BROADCAST_ACTION_MUTE - val pendingMuteIntent = PendingIntent.getBroadcast(context, 0, muteMediaIntent, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - // 将 Service 设置为前台服务,并创建一个通知 + // Build Notification return NotificationCompat.Builder(this, CHANNEL_ID_DEFAULT) - .setContentTitle(getString(R.string.to_be_or_not)) + .setContentTitle(getString(R.string.to_be_or_not)) // Consider more descriptive title .setOnlyAlertOnce(true) .setContentText(String.format( resources.getString(R.string.current_volume_percent), - volumeComment[currentVolumeLevel], + comment, currentVolume)) .setSmallIcon(nIcon) .setOngoing(true) @@ -448,19 +544,21 @@ class ForegroundService: Service() { .setPriority(NotificationCompat.PRIORITY_LOW) .setGroup(ID_NOTIFICATION_GROUP_PROTECT) .setTimeoutAfter(3000) + .setGroupSummary(false) .build() } @SuppressLint("DiscouragedApi") private fun generateNotificationIcon(context: Context, iconMode: Int): IconCompat { val currentVolume = getVolumePercentage(context) - val currentVolumeLevel = getVolumeLevel(currentVolume) - if (iconMode == MODE_NUM) { - val resourceId = resources.getIdentifier("num_$currentVolume", "drawable", context.packageName) - return IconCompat.createWithResource(this, resourceId) - } - else { - return IconCompat.createWithResource(context, volumeDrawableIds[currentVolumeLevel]) + + return if (iconMode == MODE_NUM) { + val resourceName = "num_${currentVolume}" + val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName) + if (resourceId != 0) IconCompat.createWithResource(this, resourceId) + else IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) // Fallback to image mode + } else { + IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) } } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index 357eeac..c82cb7c 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -9,6 +9,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.SharedPreferences import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.net.Uri @@ -41,6 +42,13 @@ import com.maary.liveinpeace.R class QSTileService: TileService() { + private lateinit var sharedPreferences: SharedPreferences + + override fun onCreate() { + super.onCreate() + sharedPreferences = getSharedPreferences(Constants.SHARED_PREF, Context.MODE_PRIVATE) + } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onClick() { super.onClick() @@ -134,18 +142,19 @@ class QSTileService: TileService() { val intent = Intent(this, ForegroundService::class.java) + val currentlyRunning = sharedPreferences.getBoolean(Constants.PREF_SERVICE_RUNNING, false) // Check persisted state - if (!ForegroundService.isForegroundServiceRunning()){ + if (!currentlyRunning) { + Log.d("QSTileService", "onClick: Starting ForegroundService.") applicationContext.startForegroundService(intent) - tile.state = Tile.STATE_ACTIVE - tile.icon = Icon.createWithResource(this, R.drawable.icon_qs_one) - tile.label = getString(R.string.qstile_active) - - }else{ + // Optimistically update the tile, receiver will correct if needed + updateTileState(true) + } else { + Log.d("QSTileService", "onClick: Stopping ForegroundService.") applicationContext.stopService(intent) - tile.state = Tile.STATE_INACTIVE - tile.icon = Icon.createWithResource(this, R.drawable.icon_qs_off) - tile.label = getString(R.string.qstile_inactive) + // Optimistically update the tile, receiver will correct if needed + sharedPreferences.edit { putBoolean(Constants.PREF_SERVICE_RUNNING, false) } + updateTileState(false) } tile.updateTile() } @@ -153,8 +162,12 @@ class QSTileService: TileService() { @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onStartListening() { super.onStartListening() + // Update tile based on persisted state initially + updateTileState(sharedPreferences.getBoolean(Constants.PREF_SERVICE_RUNNING, false)) + val intentFilter = IntentFilter() - intentFilter.addAction(BROADCAST_ACTION_FOREGROUND) + intentFilter.addAction(Constants.BROADCAST_ACTION_FOREGROUND) + // Use RECEIVER_NOT_EXPORTED for security with internal broadcasts registerReceiver(foregroundServiceReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } @@ -165,29 +178,29 @@ class QSTileService: TileService() { private val foregroundServiceReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - Log.v("MUTE_QS", "TRIGGERED") - - val isForegroundServiceRunning = intent.getBooleanExtra( - BROADCAST_FOREGROUND_INTENT_EXTRA, false) - // 在此处处理前台服务状态的变化 - val tile = qsTile - - if (!isForegroundServiceRunning){ - Log.v("MUTE_QS", "NOT RUNNING") - tile.state = Tile.STATE_INACTIVE - tile.icon = Icon.createWithResource(context, R.drawable.icon_qs_off) - tile.label = getString(R.string.qstile_inactive) - val foregroundIntent = Intent(context, ForegroundService::class.java) - applicationContext.startForegroundService(foregroundIntent) - }else{ - tile.state = Tile.STATE_ACTIVE - tile.icon = Icon.createWithResource(context, R.drawable.icon_qs_one) - tile.label = getString(R.string.qstile_active) + if (intent.action == Constants.BROADCAST_ACTION_FOREGROUND) { + val isRunning = intent.getBooleanExtra(Constants.BROADCAST_FOREGROUND_INTENT_EXTRA, false) + Log.d("QSTileService", "Received foreground service state update: isRunning=$isRunning") + updateTileState(isRunning) } - tile.updateTile() } } + private fun updateTileState(isRunning: Boolean) { + val tile = qsTile ?: return // Tile might be null if called before ready + + if (isRunning) { + tile.state = Tile.STATE_ACTIVE + tile.icon = Icon.createWithResource(this, R.drawable.icon_qs_one) // Active icon + tile.label = getString(R.string.qstile_active) + } else { + tile.state = Tile.STATE_INACTIVE + tile.icon = Icon.createWithResource(this, R.drawable.icon_qs_off) // Inactive icon + tile.label = getString(R.string.qstile_inactive) + } + tile.updateTile() + } + private fun createNotificationChannel(importance:Int, id: String ,name:String, descriptionText: String) { //val importance = NotificationManager.IMPORTANCE_DEFAULT val channel = NotificationChannel(id, name, importance).apply { diff --git a/build.gradle b/build.gradle index 8e89450..438674a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.5.1' apply false - id 'com.android.library' version '8.5.1' apply false + id 'com.android.application' version '8.7.3' apply false + id 'com.android.library' version '8.7.3' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'com.google.devtools.ksp' version "2.0.0-1.0.21" apply false } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2724bc9..910c73c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri May 12 10:24:55 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 8b2aec489cdfce2045b4ff96ab08163c808c4ad5 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:23:31 +0800 Subject: [PATCH 02/48] bump versionName --- app/build.gradle | 19 ++++++++------ .../com/maary/liveinpeace/HistoryActivity.kt | 10 +++----- .../maary/liveinpeace/database/Connection.kt | 3 +-- .../liveinpeace/service/ForegroundService.kt | 25 +++++++++---------- .../liveinpeace/service/QSTileService.kt | 5 +--- 5 files changed, 29 insertions(+), 33 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3058c2a..2f27c7f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,9 +18,9 @@ android { defaultConfig { applicationId "com.maary.liveinpeace" minSdk 31 - targetSdk 34 + targetSdk 35 versionCode 5 - versionName "2.3_beta" + versionName "2025.04.05-01" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -70,18 +70,21 @@ android { kotlinOptions { jvmTarget = '1.8' } - applicationVariants.all { variant -> - variant.outputs.all { output -> - // ... - def abi = output.getFilter(com.android.build.OutputFile.ABI) + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { output -> + // 查找 ABI 过滤器 + def abiFilter = output.filters.find { it.filterType == "ABI" } + def abi = abiFilter?.identifier + def apkName = "LiveInPeace-${abi}.apk" - if (abi.contains("x86_64")) { + if (abi == "x86_64") { apkName = "LiveInPeace-x64.apk" } - outputFileName = apkName + output.outputFileName = apkName } } + } dependencies { diff --git a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt index c1d45e0..0fc5b14 100644 --- a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt @@ -10,25 +10,23 @@ import android.util.Log import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat // For receiver registration +import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.MaterialDatePicker -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE // Import new constant -import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST // Import new constant +import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE +import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_BUTTON import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_DATABASE import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.databinding.ActivityHistoryBinding -// import com.maary.liveinpeace.service.ForegroundService // No longer needed for static access import java.text.SimpleDateFormat import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Locale -import java.util.concurrent.TimeUnit // Keep TimeUnit for duration calculation // Remove DeviceMapChangeListener from the class declaration @@ -87,7 +85,7 @@ class HistoryActivity : AppCompatActivity() { // Use DateTimeFormatter for LocalDate // Define the formatter using the pattern from Constants - val dbDateFormatter = DateTimeFormatter.ofPattern(Constants.Companion.PATTERN_DATE_DATABASE, Locale.getDefault()) + val dbDateFormatter = DateTimeFormatter.ofPattern(PATTERN_DATE_DATABASE, Locale.getDefault()) var pickedDate: String = LocalDate.now().format(dbDateFormatter) // Use the correct formatter // Makes only dates from today backward selectable. diff --git a/app/src/main/java/com/maary/liveinpeace/database/Connection.kt b/app/src/main/java/com/maary/liveinpeace/database/Connection.kt index 9afcd0b..0e95b97 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/Connection.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/Connection.kt @@ -4,8 +4,7 @@ import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.android.parcel.Parcelize -import java.sql.Date +import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "connection_table") 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 01e5dc9..478adb4 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -9,7 +9,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences // Import SharedPreferences +import android.content.SharedPreferences import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo @@ -21,21 +21,20 @@ import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.edit // Import SharedPreferences edit extension +import androidx.core.content.edit import androidx.core.graphics.drawable.IconCompat -import com.maary.liveinpeace.Constants // Keep using Constants import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS import com.maary.liveinpeace.Constants.Companion.ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT import com.maary.liveinpeace.Constants.Companion.ALERT_TIME +import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_FOREGROUND -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE // New Action import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_MUTE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_TOGGLE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_UPDATE import com.maary.liveinpeace.Constants.Companion.BROADCAST_FOREGROUND_INTENT_EXTRA import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT -import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST // New Extra Key +import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_ALERT import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_FOREGROUND import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_FORE @@ -45,7 +44,7 @@ import com.maary.liveinpeace.Constants.Companion.MODE_IMG import com.maary.liveinpeace.Constants.Companion.MODE_NUM import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION import com.maary.liveinpeace.Constants.Companion.PREF_ICON -import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING // New Pref Key +import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME import com.maary.liveinpeace.Constants.Companion.SHARED_PREF import com.maary.liveinpeace.DeviceTimer @@ -60,12 +59,12 @@ import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel // Import cancel +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.text.DateFormat import java.time.LocalDate import java.util.Date -import java.util.concurrent.ConcurrentHashMap // Use ConcurrentHashMap for thread safety if needed, though access seems main-thread confined +import java.util.concurrent.ConcurrentHashMap class ForegroundService : Service() { @@ -103,7 +102,7 @@ class ForegroundService : Service() { } - private fun getVolumePercentage(context: Context): Int { + private fun getVolumePercentage(): Int { val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) // Avoid division by zero if maxVolume is 0 @@ -214,12 +213,12 @@ class ForegroundService : Service() { var boolProtected = false // Use loop with counter or check to prevent infinite loop if volume doesn't change var attempts = 100 // Limit attempts - while (getVolumePercentage(applicationContext) > 25 && attempts-- > 0) { + while (getVolumePercentage() > 25 && attempts-- > 0) { boolProtected = true audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) } attempts = 100 // Reset attempts - while (getVolumePercentage(applicationContext) < 10 && attempts-- > 0) { + while (getVolumePercentage() < 10 && attempts-- > 0) { boolProtected = true audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) } @@ -451,7 +450,7 @@ class ForegroundService : Service() { @SuppressLint("LaunchActivityFromNotification") // If PendingIntent launches Activity fun createForegroundNotification(context: Context): Notification { - val currentVolume = getVolumePercentage(context) + val currentVolume = getVolumePercentage() val currentVolumeLevel = getVolumeLevel(currentVolume) // Ensure volumeComment is initialized val comment = if (::volumeComment.isInitialized && currentVolumeLevel < volumeComment.size) { @@ -550,7 +549,7 @@ class ForegroundService : Service() { @SuppressLint("DiscouragedApi") private fun generateNotificationIcon(context: Context, iconMode: Int): IconCompat { - val currentVolume = getVolumePercentage(context) + val currentVolume = getVolumePercentage() return if (iconMode == MODE_NUM) { val resourceName = "num_${currentVolume}" diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index c82cb7c..53e0c84 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -24,8 +24,6 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.content.edit import com.maary.liveinpeace.Constants -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_FOREGROUND -import com.maary.liveinpeace.Constants.Companion.BROADCAST_FOREGROUND_INTENT_EXTRA import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_ALERT import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT @@ -33,7 +31,6 @@ import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SETTINGS import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SLEEPTIMER import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_WELCOME import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_SETTINGS import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_WELCOME import com.maary.liveinpeace.Constants.Companion.PREF_WELCOME_FINISHED import com.maary.liveinpeace.Constants.Companion.REQUESTING_WAIT_MILLIS @@ -46,7 +43,7 @@ class QSTileService: TileService() { override fun onCreate() { super.onCreate() - sharedPreferences = getSharedPreferences(Constants.SHARED_PREF, Context.MODE_PRIVATE) + sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) } @RequiresApi(Build.VERSION_CODES.TIRAMISU) From eeadc49b9f273576942fc2d74b8abffc92ac74bf Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 5 Apr 2025 21:56:28 +0800 Subject: [PATCH 03/48] prepare for ui --- app/build.gradle | 19 ++++- app/src/main/AndroidManifest.xml | 73 +++++++++++-------- .../com/maary/liveinpeace/HistoryActivity.kt | 4 - .../com/maary/liveinpeace/MainActivity.kt | 47 ++++++++++++ .../com/maary/liveinpeace/ui/theme/Color.kt | 11 +++ .../com/maary/liveinpeace/ui/theme/Theme.kt | 58 +++++++++++++++ .../com/maary/liveinpeace/ui/theme/Type.kt | 34 +++++++++ app/src/main/res/values/strings.xml | 1 + build.gradle | 1 + 9 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/maary/liveinpeace/MainActivity.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/theme/Color.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/theme/Type.kt diff --git a/app/build.gradle b/app/build.gradle index 2f27c7f..78c0f49 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,7 @@ plugins { id 'kotlin-android' id 'com.google.devtools.ksp' id 'kotlin-parcelize' + id 'org.jetbrains.kotlin.plugin.compose' } def keystorePropertiesFile = rootProject.file("key.properties") @@ -27,6 +28,7 @@ android { buildFeatures { viewBinding true dataBinding false + compose true } splits { // Configures multiple APKs based on ABI. @@ -63,12 +65,12 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '11' } applicationVariants.configureEach { variant -> variant.outputs.configureEach { output -> @@ -96,6 +98,17 @@ dependencies { implementation 'androidx.activity:activity-ktx:1.10.1' implementation 'androidx.databinding:databinding-runtime:8.9.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.activity:activity-compose:1.10.1' + implementation platform('androidx.compose:compose-bom:2025.03.01') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + androidTestImplementation platform('androidx.compose:compose-bom:2025.03.01') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' def lifecycle_version = '2.8.7' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c04858..e889c21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,74 +1,89 @@ - + - - - - - + + + + + + + + - + + + + - - + - - + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> - - + + - - + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> - + - + - - - + - - @@ -76,10 +91,8 @@ - - \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt index 0fc5b14..24563f6 100644 --- a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt @@ -48,8 +48,6 @@ class HistoryActivity : AppCompatActivity() { super.onResume() // Register the receiver registerConnectionsUpdateReceiver() - // Remove old listener registration - // ForegroundService.addDeviceMapChangeListener(this) } override fun onPause() { @@ -57,8 +55,6 @@ class HistoryActivity : AppCompatActivity() { // Unregister the receiver unregisterReceiver(connectionsUpdateReceiver) connectionsUpdateReceiver = null // Allow garbage collection - // Remove old listener removal - // ForegroundService.removeDeviceMapChangeListener(this) } // Calculates duration for currently connected items based on their connect time diff --git a/app/src/main/java/com/maary/liveinpeace/MainActivity.kt b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt new file mode 100644 index 0000000..577b2db --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt @@ -0,0 +1,47 @@ +package com.maary.liveinpeace + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.maary.liveinpeace.ui.theme.LiveInPeaceTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LiveInPeaceTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Greeting( + name = "Android", + modifier = Modifier.padding(innerPadding) + ) + } + } + } + } +} + +@Composable +fun Greeting(name: String, modifier: Modifier = Modifier) { + Text( + text = "Hello $name!", + modifier = modifier + ) +} + +@Preview(showBackground = true) +@Composable +fun GreetingPreview() { + LiveInPeaceTheme { + Greeting("Android") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/theme/Color.kt b/app/src/main/java/com/maary/liveinpeace/ui/theme/Color.kt new file mode 100644 index 0000000..5d18db7 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.maary.liveinpeace.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt b/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt new file mode 100644 index 0000000..e5233e0 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.maary.liveinpeace.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun LiveInPeaceTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/theme/Type.kt b/app/src/main/java/com/maary/liveinpeace/ui/theme/Type.kt new file mode 100644 index 0000000..2104d84 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.maary.liveinpeace.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ 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 4cb13ab..f0a93c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,4 +51,5 @@ Is that ASMR pleasing? Let\'s ROCK + MainActivity \ No newline at end of file diff --git a/build.gradle b/build.gradle index 438674a..02b4f87 100644 --- a/build.gradle +++ b/build.gradle @@ -4,4 +4,5 @@ plugins { id 'com.android.library' version '8.7.3' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'com.google.devtools.ksp' version "2.0.0-1.0.21" apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false } \ No newline at end of file From d191261288bcc11b6190e8d944cd53e949f10978 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 5 May 2025 18:39:26 +0800 Subject: [PATCH 04/48] upgrade agp version --- build.gradle | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 02b4f87..0785d73 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.7.3' apply false - id 'com.android.library' version '8.7.3' apply false + id 'com.android.application' version '8.9.2' apply false + id 'com.android.library' version '8.9.2' apply false id 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'com.google.devtools.ksp' version "2.0.0-1.0.21" apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 910c73c..eaaae2b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri May 12 10:24:55 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 1f1e9d9081d83208d31c8c00cd1d07db60cab32f Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 5 May 2025 18:43:07 +0800 Subject: [PATCH 05/48] update dependency version & add preference datastore --- app/build.gradle | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 78c0f49..0cb78ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -91,21 +91,21 @@ android { dependencies { - implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.core:core-ktx:1.16.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.activity:activity-ktx:1.10.1' - implementation 'androidx.databinding:databinding-runtime:8.9.1' + implementation 'androidx.databinding:databinding-runtime:8.9.2' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' implementation 'androidx.activity:activity-compose:1.10.1' - implementation platform('androidx.compose:compose-bom:2025.03.01') + implementation platform('androidx.compose:compose-bom:2025.04.01') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.material3:material3' - androidTestImplementation platform('androidx.compose:compose-bom:2025.03.01') + androidTestImplementation platform('androidx.compose:compose-bom:2025.04.01') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' @@ -120,13 +120,15 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" - def room_version = '2.6.1' + def room_version = '2.7.1' implementation "androidx.room:room-runtime:$room_version" annotationProcessor "androidx.room:room-compiler:$room_version" - implementation 'androidx.room:room-ktx:2.6.1' + implementation 'androidx.room:room-ktx:2.7.1' ksp "androidx.room:room-compiler:$room_version" + implementation "androidx.datastore:datastore-preferences:1.2.0-alpha01" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' From fbcb29b3f80d32afd04a8adbc5fbfa1718641378 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Tue, 27 May 2025 20:56:30 +0800 Subject: [PATCH 06/48] Implement DataStore and add Hilt for dependency injection This commit introduces DataStore for managing application preferences, migrating from SharedPreferences. It also integrates Hilt for dependency injection, setting up the `LiveInPeaceApplication` class. A new preference `PREF_HIDE_IN_LAUNCHER` has been added. --- app/build.gradle | 4 +- .../java/com/maary/liveinpeace/Constants.kt | 1 + .../liveinpeace/LiveInPeaceApplication.kt | 8 ++ .../database/PreferenceRepository.kt | 123 ++++++++++++++++++ build.gradle | 1 + 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt diff --git a/app/build.gradle b/app/build.gradle index 0cb78ee..c4de306 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ plugins { id 'com.google.devtools.ksp' id 'kotlin-parcelize' id 'org.jetbrains.kotlin.plugin.compose' + id 'com.google.dagger.hilt.android' } def keystorePropertiesFile = rootProject.file("key.properties") @@ -110,7 +111,6 @@ dependencies { debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' - def lifecycle_version = '2.8.7' // LiveData @@ -119,6 +119,8 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" + implementation "com.google.dagger:hilt-android:2.56.1" + ksp "com.google.dagger:hilt-compiler:2.56.1" def room_version = '2.7.1' diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index 589e092..b7da454 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -18,6 +18,7 @@ class Constants { const val PREF_WELCOME_FINISHED = "welcome_finished" // SharedPreferences key for service running state const val PREF_SERVICE_RUNNING = "service_running_state" + const val PREF_HIDE_IN_LAUNCHER = "hide_in_launcher" // 设置通知 id const val ID_NOTIFICATION_SETTINGS = 3 // 前台通知 id diff --git a/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt b/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt new file mode 100644 index 0000000..2781e5d --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt @@ -0,0 +1,8 @@ +package com.maary.liveinpeace + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class LiveInPeaceApplication: Application() { +} \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt new file mode 100644 index 0000000..0ebd9b2 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -0,0 +1,123 @@ +package com.maary.liveinpeace.database + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.SharedPreferencesMigration +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.maary.liveinpeace.Constants +import com.maary.liveinpeace.Constants.Companion.MODE_IMG +import com.maary.liveinpeace.Constants.Companion.MODE_NUM +import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION +import com.maary.liveinpeace.Constants.Companion.PREF_ICON +import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING +import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME +import com.maary.liveinpeace.Constants.Companion.PREF_WELCOME_FINISHED +import com.maary.liveinpeace.Constants.Companion.SHARED_PREF +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +val Context.datastore: DataStore by preferencesDataStore( + name = "live_in_peace_settings", + produceMigrations = { context -> + listOf(SharedPreferencesMigration(context, SHARED_PREF)) + } +) + +class PreferenceRepository @Inject constructor(@ApplicationContext context: Context) { + + private val datastore = context.datastore + + companion object { + val PREF_ICON = intPreferencesKey(Constants.PREF_ICON) + val PREF_WATCHING_CONNECTING_TIME = booleanPreferencesKey(Constants.PREF_WATCHING_CONNECTING_TIME) + val PREF_ENABLE_EAR_PROTECTION = booleanPreferencesKey(Constants.PREF_ENABLE_EAR_PROTECTION) + val PREF_WELCOME_FINISHED = booleanPreferencesKey(Constants.PREF_WELCOME_FINISHED) + val PREF_SERVICE_RUNNING = booleanPreferencesKey(Constants.PREF_SERVICE_RUNNING) + val PREF_HIDE_IN_LAUNCHER = booleanPreferencesKey(Constants.PREF_HIDE_IN_LAUNCHER) + } + + fun getIconState() : Flow { + return datastore.data.map { preferences -> + val iconValue = preferences[PREF_ICON] + + when (iconValue) { + MODE_IMG -> MODE_IMG + MODE_NUM -> MODE_NUM + null -> MODE_IMG + else -> MODE_IMG + } + } + } + + suspend fun setIconState(state: Int) { + datastore.edit { pref -> + pref[PREF_ICON] = state + } + } + + fun getWatchingState(): Flow { + return datastore.data.map { pref -> + pref[PREF_WATCHING_CONNECTING_TIME] ?: false + } + } + + suspend fun setWatchingState(state: Boolean) { + datastore.edit { pref -> + pref[PREF_WATCHING_CONNECTING_TIME] = state + } + } + + fun isEarProtectionOn() : Flow { + return datastore.data.map { pref -> + pref[PREF_ENABLE_EAR_PROTECTION] ?: false + } + } + + suspend fun setEarProtection(state: Boolean) { + datastore.edit { pref -> + pref[PREF_ENABLE_EAR_PROTECTION] = state + } + } + + fun isWelcomeFinished(): Flow { + return datastore.data.map { pref -> + pref[PREF_WELCOME_FINISHED] ?: false + } + } + + suspend fun setWelcomeFinished(state: Boolean) { + datastore.edit { pref -> + pref[PREF_WELCOME_FINISHED] = state + } + } + + fun isServiceRunning() : Flow { + return datastore.data.map { pref -> + pref[PREF_SERVICE_RUNNING] ?: false + } + } + + suspend fun setServiceRunning(state: Boolean) { + datastore.edit { pref -> + pref[PREF_SERVICE_RUNNING] = state + } + } + + fun isHideInLauncher() : Flow { + return datastore.data.map { pref -> + pref[PREF_HIDE_IN_LAUNCHER] ?: false + } + } + + suspend fun setHideInLauncher(state: Boolean) { + datastore.edit { pref -> + pref[PREF_HIDE_IN_LAUNCHER] = state + } + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0785d73..cd72a90 100644 --- a/build.gradle +++ b/build.gradle @@ -5,4 +5,5 @@ plugins { id 'org.jetbrains.kotlin.android' version '2.0.0' apply false id 'com.google.devtools.ksp' version "2.0.0-1.0.21" apply false id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false + id 'com.google.dagger.hilt.android' version '2.56.1' apply false } \ No newline at end of file From b3aaa4441a3018e42f065f23cc0bda43bb41fc30 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 10:46:11 +0800 Subject: [PATCH 07/48] Create notification channels in Application class and update dependencies This commit moves the creation of notification channels from the `QSTileService` to the `LiveInPeaceApplication` class. This ensures channels are created when the application starts. Additionally, several dependencies have been updated to their latest versions, including: - `androidx.core:core-ktx` - `androidx.appcompat:appcompat` - `androidx.databinding:databinding-runtime` - `androidx.lifecycle:lifecycle-runtime-ktx` - `androidx.compose:compose-bom` - `androidx.lifecycle:lifecycle-livedata-ktx` and related lifecycle components - `com.google.dagger:hilt-android` - `androidx.datastore:datastore-preferences` New dependencies added: - `androidx.lifecycle:lifecycle-viewmodel-compose` - `com.google.accompanist:accompanist-permissions` - `androidx.compose.material:material-icons-extended` --- app/build.gradle | 18 +++--- .../liveinpeace/LiveInPeaceApplication.kt | 62 +++++++++++++++++++ .../liveinpeace/service/QSTileService.kt | 55 ---------------- 3 files changed, 73 insertions(+), 62 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c4de306..17bcbe6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,33 +93,34 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.16.0' - implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.activity:activity-ktx:1.10.1' - implementation 'androidx.databinding:databinding-runtime:8.9.2' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.databinding:databinding-runtime:8.10.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.1' implementation 'androidx.activity:activity-compose:1.10.1' implementation platform('androidx.compose:compose-bom:2025.04.01') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.material3:material3' - androidTestImplementation platform('androidx.compose:compose-bom:2025.04.01') + androidTestImplementation platform('androidx.compose:compose-bom:2025.06.00') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' - def lifecycle_version = '2.8.7' + def lifecycle_version = '2.9.1' // LiveData implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1' - implementation "com.google.dagger:hilt-android:2.56.1" + implementation "com.google.dagger:hilt-android:2.56.2" ksp "com.google.dagger:hilt-compiler:2.56.1" def room_version = '2.7.1' @@ -129,7 +130,10 @@ dependencies { implementation 'androidx.room:room-ktx:2.7.1' ksp "androidx.room:room-compiler:$room_version" - implementation "androidx.datastore:datastore-preferences:1.2.0-alpha01" + implementation "androidx.datastore:datastore-preferences:1.2.0-alpha02" + + implementation("com.google.accompanist:accompanist-permissions:0.37.3") + implementation "androidx.compose.material:material-icons-extended:1.7.8" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt b/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt index 2781e5d..a4ee4c7 100644 --- a/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt +++ b/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt @@ -1,8 +1,70 @@ package com.maary.liveinpeace import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_ALERT +import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT +import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT +import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SETTINGS +import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_WELCOME import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class LiveInPeaceApplication: Application() { + + override fun onCreate() { + super.onCreate() + createNotificationChannels() + } + + private fun createNotificationChannels() { + + createNotificationChannel( + NotificationManager.IMPORTANCE_MIN, + CHANNEL_ID_DEFAULT, + resources.getString(R.string.default_channel), + resources.getString(R.string.default_channel_description) + ) + + createNotificationChannel( + NotificationManager.IMPORTANCE_MIN, + CHANNEL_ID_SETTINGS, + resources.getString(R.string.channel_settings), + resources.getString(R.string.settings_channel_description) + ) + + createNotificationChannel( + NotificationManager.IMPORTANCE_HIGH, + CHANNEL_ID_ALERT, + resources.getString(R.string.channel_alert), + resources.getString(R.string.alert_channel_description) + ) + + createNotificationChannel( + NotificationManager.IMPORTANCE_LOW, + CHANNEL_ID_PROTECT, + resources.getString(R.string.channel_protection), + resources.getString(R.string.protection_channel_description) + ) + + createNotificationChannel( + NotificationManager.IMPORTANCE_MIN, + CHANNEL_ID_WELCOME, + resources.getString(R.string.welcome_channel), + resources.getString(R.string.welcome_channel_description) + ) + } + + private fun createNotificationChannel(importance:Int, id: String ,name:String, descriptionText: String) { + //val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(id, name, importance).apply { + description = descriptionText + } + // Register the channel with the system + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index 53e0c84..5157881 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -52,61 +52,6 @@ class QSTileService: TileService() { val tile = qsTile var waitMillis = REQUESTING_WAIT_MILLIS - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (notificationManager.getNotificationChannel(CHANNEL_ID_DEFAULT) == null){ - createNotificationChannel( - NotificationManager.IMPORTANCE_MIN, - CHANNEL_ID_DEFAULT, - resources.getString(R.string.default_channel), - resources.getString(R.string.default_channel_description) - ) - } - if (notificationManager.getNotificationChannel(CHANNEL_ID_SETTINGS) == null) { - createNotificationChannel( - NotificationManager.IMPORTANCE_MIN, - CHANNEL_ID_SETTINGS, - resources.getString(R.string.channel_settings), - resources.getString(R.string.settings_channel_description) - ) - } - if (notificationManager.getNotificationChannel(CHANNEL_ID_ALERT) == null) { - createNotificationChannel( - NotificationManager.IMPORTANCE_HIGH, - CHANNEL_ID_ALERT, - resources.getString(R.string.channel_alert), - resources.getString(R.string.alert_channel_description) - ) - } - if (notificationManager.getNotificationChannel(CHANNEL_ID_PROTECT) == null) { - val channel = NotificationChannel( - CHANNEL_ID_PROTECT, - resources.getString(R.string.channel_protection), - NotificationManager.IMPORTANCE_LOW).apply { - description = resources.getString(R.string.protection_channel_description) - enableVibration(false) - setSound(null, null) - } - // Register the channel with the system - notificationManager.createNotificationChannel(channel) - } - if (notificationManager.getNotificationChannel(CHANNEL_ID_WELCOME) == null){ - createNotificationChannel( - NotificationManager.IMPORTANCE_MIN, - CHANNEL_ID_WELCOME, - resources.getString(R.string.welcome_channel), - resources.getString(R.string.welcome_channel_description) - ) - } - - if (notificationManager.getNotificationChannel(CHANNEL_ID_SLEEPTIMER) == null){ - createNotificationChannel( - NotificationManager.IMPORTANCE_MIN, - CHANNEL_ID_SLEEPTIMER, - resources.getString(R.string.sleeptimer_channel), - resources.getString(R.string.sleeptimer_channel_description) - ) - } - val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager while(ActivityCompat.checkSelfPermission( From 344728c0780d735ca1ee16ba1b61d37fe0e42b9e Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 10:46:47 +0800 Subject: [PATCH 08/48] Move ConnectionViewModel to viewmodel package This commit moves the `ConnectionViewModel` class from the `com.maary.liveinpeace` package to the `com.maary.liveinpeace.viewmodel` package. The import statements in `HistoryActivity.kt` have been updated accordingly. --- app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt | 2 ++ .../maary/liveinpeace/{ => viewmodel}/ConnectionViewModel.kt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename app/src/main/java/com/maary/liveinpeace/{ => viewmodel}/ConnectionViewModel.kt (96%) diff --git a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt index 24563f6..e6d48e5 100644 --- a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt @@ -22,6 +22,8 @@ import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_BUTTON import com.maary.liveinpeace.Constants.Companion.PATTERN_DATE_DATABASE import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.databinding.ActivityHistoryBinding +import com.maary.liveinpeace.viewmodel.ConnectionViewModel +import com.maary.liveinpeace.viewmodel.ConnectionViewModelFactory import java.text.SimpleDateFormat import java.time.LocalDate import java.time.format.DateTimeFormatter diff --git a/app/src/main/java/com/maary/liveinpeace/ConnectionViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/ConnectionViewModel.kt similarity index 96% rename from app/src/main/java/com/maary/liveinpeace/ConnectionViewModel.kt rename to app/src/main/java/com/maary/liveinpeace/viewmodel/ConnectionViewModel.kt index 202b02d..0f08bd6 100644 --- a/app/src/main/java/com/maary/liveinpeace/ConnectionViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/ConnectionViewModel.kt @@ -1,4 +1,4 @@ -package com.maary.liveinpeace +package com.maary.liveinpeace.viewmodel import androidx.lifecycle.* import com.maary.liveinpeace.database.Connection From f9abd793c36f2e76caef0538a24bf750f7a36398 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 10:47:12 +0800 Subject: [PATCH 09/48] feat: implement settings screen This commit introduces a new settings screen with the following features: - Toggle for foreground service - Toggle for alert notifications - Toggle for ear protection - Toggle for hiding the app icon in the launcher - Navigation to system notification settings - Navigation to connection history (placeholder) It also includes: - `SettingsViewModel` to manage settings state and logic. - `SettingsScreen.kt` for the UI implementation. - `SettingsComponents.kt` for reusable UI components. - New string resources for the settings screen. --- .../com/maary/liveinpeace/MainActivity.kt | 10 +- .../ui/screen/SettingsComponents.kt | 161 ++++++++++++++++++ .../liveinpeace/ui/screen/SettingsScreen.kt | 155 +++++++++++++++++ .../viewmodel/SettingsViewModel.kt | 93 ++++++++++ app/src/main/res/values/strings.xml | 6 + 5 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt diff --git a/app/src/main/java/com/maary/liveinpeace/MainActivity.kt b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt index 577b2db..8390170 100644 --- a/app/src/main/java/com/maary/liveinpeace/MainActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt @@ -11,20 +11,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.maary.liveinpeace.ui.screen.SettingsScreen import com.maary.liveinpeace.ui.theme.LiveInPeaceTheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { LiveInPeaceTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + SettingsScreen() } } } diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt new file mode 100644 index 0000000..8cd749b --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -0,0 +1,161 @@ +package com.maary.liveinpeace.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.maary.liveinpeace.ui.theme.Typography +import com.maary.liveinpeace.R + +@Composable +fun TextContent(modifier: Modifier = Modifier, title: String, description: String) { + Column(modifier = modifier){ + Text( + title, + style = Typography.titleLarge + ) + Text( + description, + style = Typography.bodySmall, + maxLines = 5 + ) + } +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DropdownItem(modifier: Modifier, options: MutableList, position: Int, onItemClicked: (Int) -> Unit) { + var expanded by remember { + mutableStateOf(false) + } + + Box(modifier = modifier) { + ExposedDropdownMenuBox( + modifier = + Modifier.padding(8.dp), + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + modifier = Modifier + .wrapContentWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable), + value = options[position],//text, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + ExposedDropdownMenu( + modifier = Modifier.wrapContentWidth(), + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + modifier = Modifier.wrapContentWidth(), + text = { Text(option, style = Typography.bodyLarge) }, + onClick = { + expanded = false + onItemClicked(options.indexOf(option)) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } +} + +@Composable +fun SwitchRow( + title: String, + description: String, + state: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures { + onCheckedChange(!state) // 当点击 SwitchRow 时触发点击事件 + } + } + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextContent(modifier = Modifier.weight(1f), title = title, description = description) + Switch(checked = state, onCheckedChange = onCheckedChange) + } +} + +@Composable +fun EnableForegroundRow( + state: Boolean, + onCheckedChange: (Boolean) -> Unit, + onNotificationSettingsClicked: () -> Unit +) { + Column { + SwitchRow( + title = stringResource(id = R.string.default_channel), + description = stringResource(id = R.string.default_channel_description), + state = state, + onCheckedChange = onCheckedChange) + if (state) { + TextContent( + modifier = Modifier + .fillMaxWidth() + .clickable { onNotificationSettingsClicked() } + .padding(start = 32.dp, top = 8.dp, end = 32.dp, bottom = 8.dp), + title = stringResource(id = R.string.notification_settings), + description = stringResource(R.string.notification_settings_description)) + } + } +} + +@Composable +fun DropdownRow(options: MutableList, position: Int, onItemClicked: (Int) -> Unit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextContent( + modifier = Modifier.weight(3f), + title = stringResource(id = R.string.icon_type), + description = stringResource(id = R.string.icon_type_description) + ) + DropdownItem(modifier = Modifier.weight(2f), options = options, + position = position, onItemClicked = onItemClicked) + } +} \ 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 new file mode 100644 index 0000000..dcfcb58 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt @@ -0,0 +1,155 @@ +package com.maary.liveinpeace.ui.screen + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.maary.liveinpeace.ui.theme.Typography +import com.maary.liveinpeace.R +import com.maary.liveinpeace.viewmodel.SettingsViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberPermissionState + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) + + val requestPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) {} + + LaunchedEffect(notificationPermissionState) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + Scaffold ( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Text(stringResource(R.string.app_name)) + }, + navigationIcon = { + IconButton(onClick = { /* TODO: exit activity */ }) { + Icon(imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.exit)) + } + }, + actions = { + IconButton(onClick = {/* TODO: open history activity*/} ) { + Icon(painter = painterResource(R.drawable.ic_action_history), + contentDescription = stringResource(R.string.connections_history)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()) + .verticalScroll(rememberScrollState()) + ){ + + /** + * 1. 启用状态 + * 2. 提醒状态 + * 3. 音量保护 + * 3.1 安全音量阈值 + * 4. 隐藏桌面图标 + * */ + + EnableForegroundRow( + state = settingsViewModel.foregroundSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.foregroundSwitch() }, + onNotificationSettingsClicked = { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + }, + ) + + SwitchRow( + title = stringResource(R.string.enable_watching), + description = stringResource(R.string.enable_watching) /* todo */, + state = settingsViewModel.alertSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.alertSwitch() } + ) + + SwitchRow( + title = stringResource(R.string.protection), + description = stringResource(R.string.protection) /* todo */, + state = settingsViewModel.protectionSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.protectionSwitch() } + ) + + SwitchRow( + title = stringResource(R.string.hide_in_launcher), + description = stringResource(R.string.hide_in_launcher) /* todo */, + state = settingsViewModel.hideInLauncherSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.hideInLauncherSwitch() } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..c02c5d0 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -0,0 +1,93 @@ +package com.maary.liveinpeace.viewmodel + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.maary.liveinpeace.database.PreferenceRepository +import com.maary.liveinpeace.service.ForegroundService +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingsViewModel @Inject constructor( + @ApplicationContext private val application: Context, + private val preferenceRepository: PreferenceRepository +): ViewModel() { + + private val _foregroundSwitchState = MutableStateFlow(false) + val foregroundSwitchState : StateFlow = _foregroundSwitchState.asStateFlow() + + fun foregroundSwitch() { + if(!_foregroundSwitchState.value) { + startForegroundService() + } else { + stopForegroundService() + } + //todo set foreground state in foreground service + } + + private fun startForegroundService() { + viewModelScope.launch { + val intent = Intent(application, ForegroundService::class.java) + application.startForegroundService(intent) + } + } + + private fun stopForegroundService() { + viewModelScope.launch { + val intent = Intent(application, ForegroundService::class.java) + application.stopService(intent) + } + } + + private val _alertSwitchState = MutableStateFlow(false) + val alertSwitchState : StateFlow = _alertSwitchState.asStateFlow() + + fun alertSwitch() { + viewModelScope.launch { + preferenceRepository.setWatchingState(!_alertSwitchState.value) + } + } + + private val _protectionSwitchState = MutableStateFlow(false) + val protectionSwitchState : StateFlow = _protectionSwitchState.asStateFlow() + + fun protectionSwitch() { + viewModelScope.launch { + preferenceRepository.setEarProtection(!_protectionSwitchState.value) + } + } + + private val _hideInLauncherSwitchState = MutableStateFlow(false) + val hideInLauncherSwitchState : StateFlow = _hideInLauncherSwitchState.asStateFlow() + + fun hideInLauncherSwitch() { + viewModelScope.launch { + preferenceRepository.setHideInLauncher(!_hideInLauncherSwitchState.value) + } + } + + init { + preferenceRepository.isServiceRunning().onEach { + _foregroundSwitchState.value = it + }.launchIn(viewModelScope) + preferenceRepository.getWatchingState().onEach { + _alertSwitchState.value = it + }.launchIn(viewModelScope) + preferenceRepository.isEarProtectionOn().onEach { + _protectionSwitchState.value = it + } + preferenceRepository.isHideInLauncher().onEach { + _hideInLauncherSwitchState.value = it + } + } + +} \ 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 f0a93c3..a81df90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,4 +52,10 @@ Let\'s ROCK MainActivity + Notification Icon Type + Choose notification icon type from percent and image. + Notification Settings + Configure notification importance + Exit + Hide In Launcher \ No newline at end of file From 3e6a003c32d549b7262980764922002d4723ca8f Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 16:54:44 +0800 Subject: [PATCH 10/48] Refactor: Use PreferenceRepository for managing preferences This commit replaces SharedPreferences with PreferenceRepository (DataStore) for managing application preferences. The following changes were made: - Removed SharedPreferences usage from `ForegroundService` and `QSTileService`. - `ForegroundService` now uses `PreferenceRepository` to access and modify preference values, including ear protection, watching state, and service running state. - `QSTileService` now uses `PreferenceRepository` to determine the service running state and manage the welcome finished state. - Removed the `PREF_ICON` preference and related logic for choosing notification icon style, defaulting to the numeric volume icon. - Removed the "Greeting" composable and its preview from `MainActivity`. - The settings action in the foreground service notification now opens `MainActivity` instead of `SettingsReceiver`. - Removed the ear protection toggle action from the foreground service notification. --- .../com/maary/liveinpeace/MainActivity.kt | 16 --- .../database/PreferenceRepository.kt | 20 --- .../liveinpeace/service/ForegroundService.kt | 121 ++++++++---------- .../liveinpeace/service/QSTileService.kt | 100 +++++++++------ 4 files changed, 113 insertions(+), 144 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/MainActivity.kt b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt index 8390170..982e851 100644 --- a/app/src/main/java/com/maary/liveinpeace/MainActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/MainActivity.kt @@ -26,20 +26,4 @@ class MainActivity : ComponentActivity() { } } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - LiveInPeaceTheme { - Greeting("Android") - } } \ No newline at end of file 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 0ebd9b2..02d6d70 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -34,7 +34,6 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont private val datastore = context.datastore companion object { - val PREF_ICON = intPreferencesKey(Constants.PREF_ICON) val PREF_WATCHING_CONNECTING_TIME = booleanPreferencesKey(Constants.PREF_WATCHING_CONNECTING_TIME) val PREF_ENABLE_EAR_PROTECTION = booleanPreferencesKey(Constants.PREF_ENABLE_EAR_PROTECTION) val PREF_WELCOME_FINISHED = booleanPreferencesKey(Constants.PREF_WELCOME_FINISHED) @@ -42,25 +41,6 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont val PREF_HIDE_IN_LAUNCHER = booleanPreferencesKey(Constants.PREF_HIDE_IN_LAUNCHER) } - fun getIconState() : Flow { - return datastore.data.map { preferences -> - val iconValue = preferences[PREF_ICON] - - when (iconValue) { - MODE_IMG -> MODE_IMG - MODE_NUM -> MODE_NUM - null -> MODE_IMG - else -> MODE_IMG - } - } - } - - suspend fun setIconState(state: Int) { - datastore.edit { pref -> - pref[PREF_ICON] = state - } - } - fun getWatchingState(): Flow { return datastore.data.map { pref -> pref[PREF_WATCHING_CONNECTING_TIME] ?: false 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 478adb4..c333820 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -9,7 +9,6 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo @@ -21,7 +20,6 @@ import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.edit import androidx.core.graphics.drawable.IconCompat import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS import com.maary.liveinpeace.Constants.Companion.ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT @@ -43,29 +41,33 @@ import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_PROTECT import com.maary.liveinpeace.Constants.Companion.MODE_IMG import com.maary.liveinpeace.Constants.Companion.MODE_NUM import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION -import com.maary.liveinpeace.Constants.Companion.PREF_ICON import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME -import com.maary.liveinpeace.Constants.Companion.SHARED_PREF import com.maary.liveinpeace.DeviceTimer +import com.maary.liveinpeace.MainActivity import com.maary.liveinpeace.R import com.maary.liveinpeace.SleepNotification.find import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.database.ConnectionDao import com.maary.liveinpeace.database.ConnectionRoomDatabase +import com.maary.liveinpeace.database.PreferenceRepository import com.maary.liveinpeace.receiver.MuteMediaReceiver import com.maary.liveinpeace.receiver.SettingsReceiver import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first import java.text.DateFormat import java.time.LocalDate import java.util.Date import java.util.concurrent.ConcurrentHashMap +@AndroidEntryPoint class ForegroundService : Service() { // Use instance scope for CoroutineScope to easily cancel jobs in onDestroy @@ -74,7 +76,9 @@ class ForegroundService : Service() { private lateinit var database: ConnectionRoomDatabase private lateinit var connectionDao: ConnectionDao private lateinit var audioManager: AudioManager - private lateinit var sharedPreferences: SharedPreferences // Instance variable for SharedPreferences + + @Inject + lateinit var preferenceRepository: PreferenceRepository // Instance variable for device map private val deviceMap: MutableMap = ConcurrentHashMap() // Use ConcurrentHashMap if worried about potential multi-threaded access, otherwise regular HashMap is fine. @@ -90,8 +94,6 @@ class ForegroundService : Service() { private lateinit var volumeComment: Array - - // Method to broadcast the current connection list private fun broadcastConnectionsUpdate() { val intent = Intent(BROADCAST_ACTION_CONNECTIONS_UPDATE) @@ -208,33 +210,35 @@ class ForegroundService : Service() { ) deviceAdded = true - // Ear Protection Logic - if (sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false)) { - var boolProtected = false - // Use loop with counter or check to prevent infinite loop if volume doesn't change - var attempts = 100 // Limit attempts - while (getVolumePercentage() > 25 && attempts-- > 0) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) - } - attempts = 100 // Reset attempts - while (getVolumePercentage() < 10 && attempts-- > 0) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) - } - if (boolProtected) { - Log.d("ForegroundService", "Ear protection applied for $deviceName") - NotificationManagerCompat.from(applicationContext).apply { - notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + serviceScope.launch { + //todo race condition + + // Ear Protection Logic + if (preferenceRepository.isEarProtectionOn().first()) { + var boolProtected = false + // Use loop with counter or check to prevent infinite loop if volume doesn't change + var attempts = 100 // Limit attempts + while (getVolumePercentage() > 25 && attempts-- > 0) { + boolProtected = true + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) + } + attempts = 100 // Reset attempts + while (getVolumePercentage() < 10 && attempts-- > 0) { + boolProtected = true + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) + } + if (boolProtected) { + Log.d("ForegroundService", "Ear protection applied for $deviceName") + NotificationManagerCompat.from(applicationContext).apply { + notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + } } } - } - // Start timer only if watching is enabled - if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)) { - if (!deviceTimerMap.containsKey(deviceName)) { - val deviceTimer = DeviceTimer(context = applicationContext, deviceName = deviceName) + // Start timer only if watching is enabled + if (preferenceRepository.getWatchingState().first()) { Log.v("MUTE_DEVICEMAP", "Starting timer for $deviceName") + val deviceTimer = DeviceTimer(context = applicationContext, deviceName = deviceName) deviceTimer.start() deviceTimerMap[deviceName] = deviceTimer } @@ -298,10 +302,12 @@ class ForegroundService : Service() { } // Stop and remove timer if watching is enabled - if (sharedPreferences.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)) { - deviceTimerMap.remove(deviceName)?.let { - Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") - it.stop() + serviceScope.launch { + if (preferenceRepository.getWatchingState().first()) { + deviceTimerMap.remove(deviceName)?.let { + Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") + it.stop() + } } } } @@ -327,8 +333,6 @@ class ForegroundService : Service() { override fun onCreate() { super.onCreate() - // Initialize SharedPreferences - sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) // Consider using a Handler for the callback thread if needed @@ -436,9 +440,9 @@ class ForegroundService : Service() { // Helper to update persisted state and send broadcast private fun setServiceRunningState(isRunning: Boolean) { - // Update SharedPreferences - sharedPreferences.edit { - putBoolean(PREF_SERVICE_RUNNING, isRunning) + // Update Preferences + serviceScope.launch { + preferenceRepository.setServiceRunning(isRunning) } // Send broadcast to notify components like QSTileService val intent = Intent(BROADCAST_ACTION_FOREGROUND) @@ -458,22 +462,16 @@ class ForegroundService : Service() { } else { "Volume" // Fallback } - val iconMode = sharedPreferences.getInt(PREF_ICON, MODE_IMG) - val nIcon = generateNotificationIcon(context, iconMode) + + val nIcon = generateNotificationIcon(context) // --- Intents for Actions --- - val settingsIntent = Intent(this, SettingsReceiver::class.java).apply { + val settingsIntent = Intent(this, MainActivity::class.java).apply { action = ACTION_NAME_SETTINGS } val settingsPendingIntent: PendingIntent = PendingIntent.getBroadcast(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Use UPDATE_CURRENT if intent extras change - val protectionIntent = Intent(this, SettingsReceiver::class.java).apply { - action = ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT - } - val protectionPendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 1, protectionIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (1) - val sleepIntent = Intent(context, MuteMediaReceiver::class.java).apply { action = BROADCAST_ACTION_SLEEPTIMER_TOGGLE } @@ -484,11 +482,6 @@ class ForegroundService : Service() { } val pendingMuteIntent = PendingIntent.getBroadcast(context, 3, muteMediaIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (3) - - // --- Determine Action Titles --- - val protectionEnabled = sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false) - val protectionActionTitle = if (protectionEnabled) R.string.dont_protect else R.string.protection - val sleepNotification = find() // From SleepNotification object val sleepTitle = if (sleepNotification != null) { DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(sleepNotification.`when`)) @@ -503,12 +496,6 @@ class ForegroundService : Service() { settingsPendingIntent ).build() - val actionProtection : NotificationCompat.Action = NotificationCompat.Action.Builder( - R.drawable.ic_headphones_protection, - resources.getString(protectionActionTitle), - protectionPendingIntent - ).build() - val actionSleepTimer: NotificationCompat.Action = NotificationCompat.Action.Builder ( R.drawable.ic_tile, // Consider a sleep-specific icon sleepTitle, @@ -530,7 +517,6 @@ class ForegroundService : Service() { .setPriority(NotificationCompat.PRIORITY_LOW) .addAction(actionSettings) .addAction(actionSleepTimer) - .addAction(actionProtection) .setGroup(ID_NOTIFICATION_GROUP_FORE) .setGroupSummary(false) .build() @@ -548,16 +534,13 @@ class ForegroundService : Service() { } @SuppressLint("DiscouragedApi") - private fun generateNotificationIcon(context: Context, iconMode: Int): IconCompat { + private fun generateNotificationIcon(context: Context): IconCompat { val currentVolume = getVolumePercentage() - return if (iconMode == MODE_NUM) { - val resourceName = "num_${currentVolume}" - val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName) - if (resourceId != 0) IconCompat.createWithResource(this, resourceId) - else IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) // Fallback to image mode - } else { - IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) - } + val resourceName = "num_${currentVolume}" + val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName) + return if (resourceId != 0) IconCompat.createWithResource(this, resourceId) + else IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) // Fallback to image mode + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index 5157881..dbae921 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -9,7 +9,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.net.Uri @@ -22,28 +21,47 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat -import androidx.core.content.edit import com.maary.liveinpeace.Constants -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_ALERT -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SETTINGS -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SLEEPTIMER import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_WELCOME import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_SETTINGS import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_WELCOME -import com.maary.liveinpeace.Constants.Companion.PREF_WELCOME_FINISHED import com.maary.liveinpeace.Constants.Companion.REQUESTING_WAIT_MILLIS -import com.maary.liveinpeace.Constants.Companion.SHARED_PREF import com.maary.liveinpeace.R +import com.maary.liveinpeace.database.PreferenceRepository +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface PreferenceQSTileEntryPoint { + fun preferenceRepository(): PreferenceRepository +} class QSTileService: TileService() { - private lateinit var sharedPreferences: SharedPreferences + private val serviceScope = CoroutineScope( SupervisorJob() + Dispatchers.IO) + private lateinit var preferenceRepository: PreferenceRepository override fun onCreate() { super.onCreate() - sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) + val entryPoint = EntryPointAccessors.fromApplication( + applicationContext, + PreferenceQSTileEntryPoint::class.java + ) + preferenceRepository = entryPoint.preferenceRepository() + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() } @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -65,39 +83,41 @@ class QSTileService: TileService() { waitMillis *= 2 } - val sharedPref = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) - while (!sharedPref.getBoolean(PREF_WELCOME_FINISHED, false)){ - if ( powerManager.isIgnoringBatteryOptimizations(packageName) && - ActivityCompat.checkSelfPermission( - applicationContext, Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED) { - sharedPref.edit { - putBoolean(PREF_WELCOME_FINISHED, true) + val intent = Intent(this, ForegroundService::class.java) + + + serviceScope.launch { + preferenceRepository.isWelcomeFinished().collect { + if (!it) { + //todo: redirect to welcome activity/screen + if ( powerManager.isIgnoringBatteryOptimizations(packageName) && + ActivityCompat.checkSelfPermission( + applicationContext, Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED) { + preferenceRepository.setWelcomeFinished(true) + } else { + createWelcomeNotification() + Thread.sleep(waitMillis.toLong()) + waitMillis *= 2 + } } - break - } else { - createWelcomeNotification() - Thread.sleep(waitMillis.toLong()) - waitMillis *= 2 } - } + if (preferenceRepository.isServiceRunning().first()) { + stopService(intent) + preferenceRepository.setServiceRunning(false) + updateTileState(false) + tile.updateTile() - val intent = Intent(this, ForegroundService::class.java) - val currentlyRunning = sharedPreferences.getBoolean(Constants.PREF_SERVICE_RUNNING, false) // Check persisted state + } else { + startForegroundService(intent) + preferenceRepository.setServiceRunning(true) + updateTileState(true) + tile.updateTile() - if (!currentlyRunning) { - Log.d("QSTileService", "onClick: Starting ForegroundService.") - applicationContext.startForegroundService(intent) - // Optimistically update the tile, receiver will correct if needed - updateTileState(true) - } else { - Log.d("QSTileService", "onClick: Stopping ForegroundService.") - applicationContext.stopService(intent) - // Optimistically update the tile, receiver will correct if needed - sharedPreferences.edit { putBoolean(Constants.PREF_SERVICE_RUNNING, false) } - updateTileState(false) + } } + tile.updateTile() } @@ -105,7 +125,9 @@ class QSTileService: TileService() { override fun onStartListening() { super.onStartListening() // Update tile based on persisted state initially - updateTileState(sharedPreferences.getBoolean(Constants.PREF_SERVICE_RUNNING, false)) + serviceScope.launch { + updateTileState(preferenceRepository.isServiceRunning().first()) + } val intentFilter = IntentFilter() intentFilter.addAction(Constants.BROADCAST_ACTION_FOREGROUND) From 9dd416f37466ce60ff73317f9e1276cce5139944 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:12:03 +0800 Subject: [PATCH 11/48] Implement MainActivityAlias and hide in launcher feature This commit introduces `MainActivityAlias` to enable hiding the app from the launcher. The `SettingsViewModel` now handles toggling the enabled state of `MainActivityAlias` based on the "Hide in Launcher" preference. The `android:foregroundServiceType` attribute has been added to the `ForegroundService` declaration in `AndroidManifest.xml` to comply with Android 14 requirements. --- app/src/main/AndroidManifest.xml | 9 ++++++++- .../maary/liveinpeace/service/ForegroundService.kt | 1 - .../liveinpeace/viewmodel/SettingsViewModel.kt | 14 +++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e889c21..0316bcc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,12 +25,19 @@ android:exported="true" android:label="@string/title_activity_main" android:theme="@style/Theme.LiveInPeace"> + + - + 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 c333820..52e0f87 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -333,7 +333,6 @@ class ForegroundService : Service() { override fun onCreate() { super.onCreate() - audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) // Consider using a Handler for the callback thread if needed 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 c02c5d0..f5356b5 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -1,9 +1,12 @@ package com.maary.liveinpeace.viewmodel +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.maary.liveinpeace.MainActivity import com.maary.liveinpeace.database.PreferenceRepository import com.maary.liveinpeace.service.ForegroundService import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,7 +34,6 @@ class SettingsViewModel @Inject constructor( } else { stopForegroundService() } - //todo set foreground state in foreground service } private fun startForegroundService() { @@ -71,6 +73,16 @@ class SettingsViewModel @Inject constructor( fun hideInLauncherSwitch() { viewModelScope.launch { + val packageManager = application.packageManager + val componentName = ComponentName(application, "${application.packageName}.MainActivityAlias") + + val newState = if(!_hideInLauncherSwitchState.value) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } + + packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) preferenceRepository.setHideInLauncher(!_hideInLauncherSwitchState.value) } } From 5f99f845b23536fe6ee4e3f53b980a0d6214865c Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 18:11:09 +0800 Subject: [PATCH 12/48] Refactor: Improve concurrency and ear protection logic This commit refactors the `ForegroundService` to enhance concurrency and the ear protection feature. - Introduced a `Mutex` to protect the `deviceMap` from concurrent access issues. - Ear protection logic is now executed within a coroutine for each connected device, allowing for individual cancellation upon device disconnection. - Removed unused constants. --- .../liveinpeace/service/ForegroundService.kt | 397 +++++++++++------- 1 file changed, 250 insertions(+), 147 deletions(-) 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 52e0f87..e515553 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -22,7 +22,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT import com.maary.liveinpeace.Constants.Companion.ALERT_TIME import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_FOREGROUND @@ -38,11 +37,6 @@ import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_FOREGROUND import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_FORE import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_PROTECT import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_PROTECT -import com.maary.liveinpeace.Constants.Companion.MODE_IMG -import com.maary.liveinpeace.Constants.Companion.MODE_NUM -import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION -import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING -import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME import com.maary.liveinpeace.DeviceTimer import com.maary.liveinpeace.MainActivity import com.maary.liveinpeace.R @@ -52,16 +46,19 @@ import com.maary.liveinpeace.database.ConnectionDao import com.maary.liveinpeace.database.ConnectionRoomDatabase import com.maary.liveinpeace.database.PreferenceRepository import com.maary.liveinpeace.receiver.MuteMediaReceiver -import com.maary.liveinpeace.receiver.SettingsReceiver import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver import dagger.hilt.android.AndroidEntryPoint import jakarta.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.text.DateFormat import java.time.LocalDate import java.util.Date @@ -81,9 +78,15 @@ class ForegroundService : Service() { lateinit var preferenceRepository: PreferenceRepository // Instance variable for device map - private val deviceMap: MutableMap = ConcurrentHashMap() // Use ConcurrentHashMap if worried about potential multi-threaded access, otherwise regular HashMap is fine. + private val deviceMap: MutableMap = + ConcurrentHashMap() // Use ConcurrentHashMap if worried about potential multi-threaded access, otherwise regular HashMap is fine. private val deviceTimerMap: MutableMap = ConcurrentHashMap() + // create mutex instance to protect deviceMap + private val deviceMapMutex = Mutex() + + private val protectionJobs = ConcurrentHashMap() + private val volumeDrawableIds = intArrayOf( R.drawable.ic_volume_silent, R.drawable.ic_volume_low, @@ -112,7 +115,7 @@ class ForegroundService : Service() { } private fun getVolumeLevel(percent: Int): Int { - return when(percent) { + return when (percent) { in 0..0 -> 0 in 1..25 -> 1 in 26..50 -> 2 @@ -125,7 +128,7 @@ class ForegroundService : Service() { @SuppressLint("MissingPermission") override fun updateNotification(context: Context) { Log.v("MUTE_TEST", "VOLUME_CHANGE_RECEIVER") - with(NotificationManagerCompat.from(applicationContext)){ + with(NotificationManagerCompat.from(applicationContext)) { notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) } } @@ -134,7 +137,7 @@ class ForegroundService : Service() { private val sleepReceiver = object : SleepReceiver() { @SuppressLint("MissingPermission") override fun updateNotification(context: Context) { - with(NotificationManagerCompat.from(applicationContext)){ + with(NotificationManagerCompat.from(applicationContext)) { notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) } } @@ -143,7 +146,8 @@ class ForegroundService : Service() { // Saves data for currently connected devices when service stops private fun saveDataForActiveConnections() { val disconnectedTime = System.currentTimeMillis() - val currentConnections = deviceMap.toMap() // Create a copy to avoid ConcurrentModificationException if needed + val currentConnections = + deviceMap.toMap() // Create a copy to avoid ConcurrentModificationException if needed deviceMap.clear() // Clear the instance map currentConnections.forEach { (_, connection) -> @@ -164,7 +168,11 @@ class ForegroundService : Service() { ) Log.d("ForegroundService", "Saved connection data for ${connection.name}") } catch (e: Exception) { - Log.e("ForegroundService", "Error saving connection data for ${connection.name}", e) + Log.e( + "ForegroundService", + "Error saving connection data for ${connection.name}", + e + ) } } } @@ -178,149 +186,216 @@ class ForegroundService : Service() { override fun onAudioDevicesAdded(addedDevices: Array?) { val connectedTime = System.currentTimeMillis() - var deviceAdded = false - addedDevices?.forEach { deviceInfo -> - if (deviceInfo.type in listOf( - AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, - AudioDeviceInfo.TYPE_BUILTIN_MIC, - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE, - AudioDeviceInfo.TYPE_FM_TUNER, - AudioDeviceInfo.TYPE_REMOTE_SUBMIX, - AudioDeviceInfo.TYPE_TELEPHONY, - 28, // Consider defining this constant if it's meaningful - ) - ) { return@forEach } - - val deviceName = deviceInfo.productName?.toString()?.trim() ?: "Unknown Device ${deviceInfo.id}" // Handle null productName - if (deviceName == Build.MODEL || deviceName.isBlank()) return@forEach // Skip self or blank names - - Log.v("MUTE_DEVICE", "Device Added: $deviceName (Type: ${deviceInfo.type})") - - // Add to instance map - if (!deviceMap.containsKey(deviceName)) { - deviceMap[deviceName] = Connection( - name = deviceName, - type = deviceInfo.type, - connectedTime = connectedTime, - disconnectedTime = null, - duration = null, - date = LocalDate.now().toString() - ) - deviceAdded = true - - serviceScope.launch { - //todo race condition - - // Ear Protection Logic - if (preferenceRepository.isEarProtectionOn().first()) { - var boolProtected = false - // Use loop with counter or check to prevent infinite loop if volume doesn't change - var attempts = 100 // Limit attempts - while (getVolumePercentage() > 25 && attempts-- > 0) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) - } - attempts = 100 // Reset attempts - while (getVolumePercentage() < 10 && attempts-- > 0) { - boolProtected = true - audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) + + serviceScope.launch { + + addedDevices?.forEach { deviceInfo -> + if (deviceInfo.type in listOf( + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_BUILTIN_MIC, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE, + AudioDeviceInfo.TYPE_FM_TUNER, + AudioDeviceInfo.TYPE_REMOTE_SUBMIX, + AudioDeviceInfo.TYPE_TELEPHONY, + 28, // Consider defining this constant if it's meaningful + ) + ) { + return@forEach + } + + val deviceName = deviceInfo.productName?.toString()?.trim() + ?: "Unknown Device ${deviceInfo.id}" // Handle null productName + if (deviceName == Build.MODEL || deviceName.isBlank()) return@forEach // Skip self or blank names + + Log.v("MUTE_DEVICE", "Device Added: $deviceName (Type: ${deviceInfo.type})") + + deviceMapMutex.withLock { + // Add to instance map + if (!deviceMap.containsKey(deviceName)) { + deviceMap[deviceName] = Connection( + name = deviceName, + type = deviceInfo.type, + connectedTime = connectedTime, + disconnectedTime = null, + duration = null, + date = LocalDate.now().toString() + ) + deviceAdded = true + + // Start timer only if watching is enabled + if (preferenceRepository.getWatchingState().first()) { + Log.v("MUTE_DEVICEMAP", "Starting timer for $deviceName") + val deviceTimer = DeviceTimer( + context = applicationContext, + deviceName = deviceName + ) + deviceTimer.start() + deviceTimerMap[deviceName] = deviceTimer } - if (boolProtected) { - Log.d("ForegroundService", "Ear protection applied for $deviceName") - NotificationManagerCompat.from(applicationContext).apply { - notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + } + } + + // Ear Protection Logic + if (preferenceRepository.isEarProtectionOn().first()) { + val protectionJob = serviceScope.launch { + try { + var boolProtected = false + // Use loop with counter or check to prevent infinite loop if volume doesn't change + var attempts = 100 // Limit attempts + while (getVolumePercentage() > 25 && attempts-- > 0) { + ensureActive() + boolProtected = true + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_LOWER, + 0 + ) + } + attempts = 100 // Reset attempts + while (getVolumePercentage() < 10 && attempts-- > 0) { + ensureActive() + boolProtected = true + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_RAISE, + 0 + ) } + if (boolProtected) { + Log.d( + "ForegroundService", + "Ear protection applied for $deviceName" + ) + NotificationManagerCompat.from(applicationContext).apply { + notify( + ID_NOTIFICATION_PROTECT, + createProtectionNotification() + ) + } + } + + } catch (e: kotlinx.coroutines.CancellationException) { + // 协程被取消时会进入这里 + Log.d("ProtectionLogic", "Protection for ${deviceInfo.address} was cancelled.") + } finally { + // 任务完成后(无论成功或取消),从 Map 中移除 + protectionJobs.remove(deviceInfo.address) } } - // Start timer only if watching is enabled - if (preferenceRepository.getWatchingState().first()) { - Log.v("MUTE_DEVICEMAP", "Starting timer for $deviceName") - val deviceTimer = DeviceTimer(context = applicationContext, deviceName = deviceName) - deviceTimer.start() - deviceTimerMap[deviceName] = deviceTimer + protectionJobs[deviceInfo.address] = protectionJob + } + + if (deviceAdded) { + Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") + // Broadcast the updated list + broadcastConnectionsUpdate() + // Update the foreground notification + NotificationManagerCompat.from(applicationContext).apply { + notify( + ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(applicationContext) + ) } } } } - if (deviceAdded) { - Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") - // Broadcast the updated list - broadcastConnectionsUpdate() - // Update the foreground notification - NotificationManagerCompat.from(applicationContext).apply { - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } - } } @SuppressLint("MissingPermission") override fun onAudioDevicesRemoved(removedDevices: Array?) { var deviceRemoved = false - removedDevices?.forEach { deviceInfo -> - val deviceName = deviceInfo.productName?.toString()?.trim() ?: return@forEach // Skip if no name - val disconnectedTime = System.currentTimeMillis() + serviceScope.launch { - if (deviceMap.containsKey(deviceName)) { - deviceRemoved = true - val connection = deviceMap.remove(deviceName) // Remove from instance map + removedDevices?.forEach { deviceInfo -> + val deviceName = deviceInfo.productName?.toString()?.trim() + ?: return@forEach // Skip if no name + val disconnectedTime = System.currentTimeMillis() + var connectionsToSave: Connection? = null - Log.v("MUTE_DEVICE", "Device Removed: $deviceName (Type: ${deviceInfo.type})") + protectionJobs[deviceInfo.address]?.let { + Log.d("ProtectionLogic", "Device ${deviceInfo.address} disconnected. Cancelling job.") + it.cancel() + } - if (connection?.connectedTime != null) { - val connectionTime = disconnectedTime - connection.connectedTime + deviceMapMutex.withLock { + if (deviceMap.containsKey(deviceName)) { + deviceRemoved = true + val connection = + deviceMap.remove(deviceName) // Remove from instance map + deviceTimerMap.remove(deviceName)?.stop() - // Cancel alert notification if connection was long - if (connectionTime > ALERT_TIME) { - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_ALERT) - } + Log.v( + "MUTE_DEVICE", + "Device Removed: $deviceName (Type: ${deviceInfo.type})" + ) - // Save data using instance scope - serviceScope.launch { - try { - connectionDao.insert( - Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, - disconnectedTime = disconnectedTime, - duration = connectionTime, - date = connection.date - ) + if (connection?.connectedTime != null) { + val connectionTime = disconnectedTime - connection.connectedTime + + connectionsToSave = Connection( + name = connection.name, + type = connection.type, + connectedTime = connection.connectedTime, + disconnectedTime = disconnectedTime, + duration = connectionTime, + date = connection.date ) - Log.d("ForegroundService", "Saved connection data for removed device ${connection.name}") - } catch (e: Exception) { - Log.e("ForegroundService", "Error saving connection data for removed device ${connection.name}", e) + + // Cancel alert notification if connection was long + if (connectionTime > ALERT_TIME) { + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(ID_NOTIFICATION_ALERT) + } + + } + + // Stop and remove timer if watching is enabled + if (preferenceRepository.getWatchingState().first()) { + deviceTimerMap.remove(deviceName)?.let { + Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") + it.stop() + } } } } - // Stop and remove timer if watching is enabled - serviceScope.launch { - if (preferenceRepository.getWatchingState().first()) { - deviceTimerMap.remove(deviceName)?.let { - Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") - it.stop() - } + connectionsToSave?.let { conn -> + try { + connectionDao.insert(conn) + Log.d( + "ForegroundService", + "Saved connection data for removed device ${conn.name}" + ) + } catch (e: Exception) { + Log.e( + "ForegroundService", + "Error saving connection data for ${conn.name}", + e + ) } + } - } - if (deviceRemoved) { - Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") - // Broadcast the updated list - broadcastConnectionsUpdate() - // Update the foreground notification - NotificationManagerCompat.from(applicationContext).apply { - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + if (deviceRemoved) { + Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") + // Broadcast the updated list + broadcastConnectionsUpdate() + // Update the foreground notification + NotificationManagerCompat.from(applicationContext).apply { + notify( + ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(applicationContext) + ) + } + deviceRemoved = false } - deviceRemoved = false + } } @@ -334,7 +409,10 @@ class ForegroundService : Service() { super.onCreate() audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) // Consider using a Handler for the callback thread if needed + audioManager.registerAudioDeviceCallback( + audioDeviceCallback, + null + ) // Consider using a Handler for the callback thread if needed // Initialize Database and DAO database = ConnectionRoomDatabase.getDatabase(applicationContext) @@ -355,7 +433,10 @@ class ForegroundService : Service() { registerReceiver(sleepReceiver, sleepFilter, RECEIVER_NOT_EXPORTED) // Start foreground - startForeground(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + startForeground( + ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(applicationContext) + ) // Update persisted state and notify components setServiceRunningState(true) @@ -396,7 +477,7 @@ class ForegroundService : Service() { deviceTimerMap.values.forEach { it.stop() } deviceTimerMap.clear() Log.d("ForegroundService", "Device timers stopped and cleared.") - } catch (e: Exception){ + } catch (e: Exception) { Log.e("ForegroundService", "Error stopping timers", e) } @@ -424,9 +505,9 @@ class ForegroundService : Service() { // Stop foreground service removal notification stopForeground(STOP_FOREGROUND_REMOVE) - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_FOREGROUND) + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(ID_NOTIFICATION_FOREGROUND) Log.d("ForegroundService", "onDestroy Finished") super.onDestroy() } @@ -456,11 +537,12 @@ class ForegroundService : Service() { val currentVolume = getVolumePercentage() val currentVolumeLevel = getVolumeLevel(currentVolume) // Ensure volumeComment is initialized - val comment = if (::volumeComment.isInitialized && currentVolumeLevel < volumeComment.size) { - volumeComment[currentVolumeLevel] - } else { - "Volume" // Fallback - } + val comment = + if (::volumeComment.isInitialized && currentVolumeLevel < volumeComment.size) { + volumeComment[currentVolumeLevel] + } else { + "Volume" // Fallback + } val nIcon = generateNotificationIcon(context) @@ -469,17 +551,32 @@ class ForegroundService : Service() { action = ACTION_NAME_SETTINGS } val settingsPendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Use UPDATE_CURRENT if intent extras change + PendingIntent.getBroadcast( + this, + 0, + settingsIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) // Use UPDATE_CURRENT if intent extras change val sleepIntent = Intent(context, MuteMediaReceiver::class.java).apply { action = BROADCAST_ACTION_SLEEPTIMER_TOGGLE } - val pendingSleepIntent = PendingIntent.getBroadcast(context, 2, sleepIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (2) + val pendingSleepIntent = PendingIntent.getBroadcast( + context, + 2, + sleepIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) // Different request code (2) val muteMediaIntent = Intent(context, MuteMediaReceiver::class.java).apply { action = BROADCAST_ACTION_MUTE } - val pendingMuteIntent = PendingIntent.getBroadcast(context, 3, muteMediaIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) // Different request code (3) + val pendingMuteIntent = PendingIntent.getBroadcast( + context, + 3, + muteMediaIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) // Different request code (3) val sleepNotification = find() // From SleepNotification object val sleepTitle = if (sleepNotification != null) { @@ -489,13 +586,13 @@ class ForegroundService : Service() { } // --- Build Actions --- - val actionSettings : NotificationCompat.Action = NotificationCompat.Action.Builder( + val actionSettings: NotificationCompat.Action = NotificationCompat.Action.Builder( R.drawable.ic_baseline_settings_24, resources.getString(R.string.settings), settingsPendingIntent ).build() - val actionSleepTimer: NotificationCompat.Action = NotificationCompat.Action.Builder ( + val actionSleepTimer: NotificationCompat.Action = NotificationCompat.Action.Builder( R.drawable.ic_tile, // Consider a sleep-specific icon sleepTitle, pendingSleepIntent @@ -506,10 +603,13 @@ class ForegroundService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID_DEFAULT) .setContentTitle(getString(R.string.to_be_or_not)) // Consider more descriptive title .setOnlyAlertOnce(true) - .setContentText(String.format( - resources.getString(R.string.current_volume_percent), - comment, - currentVolume)) + .setContentText( + String.format( + resources.getString(R.string.current_volume_percent), + comment, + currentVolume + ) + ) .setSmallIcon(nIcon) .setOngoing(true) .setContentIntent(pendingMuteIntent) @@ -539,7 +639,10 @@ class ForegroundService : Service() { val resourceName = "num_${currentVolume}" val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName) return if (resourceId != 0) IconCompat.createWithResource(this, resourceId) - else IconCompat.createWithResource(context, volumeDrawableIds[getVolumeLevel(currentVolume)]) // Fallback to image mode + else IconCompat.createWithResource( + context, + volumeDrawableIds[getVolumeLevel(currentVolume)] + ) // Fallback to image mode } } \ No newline at end of file From ae5c3f7b63fdb8ab3c1bbd00561161683c671394 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 18:22:28 +0800 Subject: [PATCH 13/48] Refactor AudioDeviceCallback in ForegroundService This commit refactors the `AudioDeviceCallback` within `ForegroundService.kt` to improve code clarity, maintainability, and robustness. Key changes include: - **Centralized Logic for Device Handling:** The `onAudioDevicesAdded` and `onAudioDevicesRemoved` methods now delegate core processing to new helper functions like `processNewDevice`, `processRemovedDevice`, `applyEarProtection`, and `saveConnectionToDatabase`. - **Improved Readability:** - Introduced constants for magic numbers (e.g., `EAR_PROTECTION_LOWER_THRESHOLD`, `VOLUME_ADJUST_ATTEMPTS`). - Used a `Set` (`IGNORED_DEVICE_TYPES`) for ignored device types, making it easier to manage. - Added more descriptive logging messages. - **Optimized Preference Reading:** Preferences like `isWatchingEnabled` and `isEarProtectionOn` are now read once outside the loop in `onAudioDevicesAdded` to avoid repeated reads. - **Enhanced Ear Protection Logic:** - The ear protection logic is now more robust, checking `isActive` within the volume adjustment loops to handle cancellations. - Uses the device name as the key for `protectionJobs` for better consistency. - **Unified UI Updates:** A new `updateUiAndNotifications` function centralizes the broadcasting of connection updates and updating the foreground notification, reducing redundancy. - **Clearer Device Name Handling:** The `getDeviceName` helper function standardizes how device names are retrieved and handles null or blank names. - **State Management:** A `hasChanges` flag is used in `onAudioDevicesAdded` and `onAudioDevicesRemoved` to track if any actual device changes occurred, preventing unnecessary UI updates. - **Error Handling:** Added a `try-catch` block around database insertion in `saveConnectionToDatabase`. - **Permission Annotation:** Added `@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)` to functions that directly trigger notifications. --- .../liveinpeace/service/ForegroundService.kt | 404 ++++++++++-------- 1 file changed, 217 insertions(+), 187 deletions(-) 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 e515553..8c6bf9b 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -17,6 +17,7 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -54,8 +55,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -182,224 +183,253 @@ class ForegroundService : Service() { } private val audioDeviceCallback = object : AudioDeviceCallback() { + + // 使用伴生对象管理常量,消除魔法数字 + private val TAG = "AudioDeviceCallback" + private val EAR_PROTECTION_LOWER_THRESHOLD = 10 + private val EAR_PROTECTION_UPPER_THRESHOLD = 25 + private val VOLUME_ADJUST_ATTEMPTS = 100 + + // 将需要忽略的设备类型定义为常量集合,方便管理 + private val IGNORED_DEVICE_TYPES = setOf( + AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, + AudioDeviceInfo.TYPE_BUILTIN_MIC, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE, + AudioDeviceInfo.TYPE_FM_TUNER, + AudioDeviceInfo.TYPE_REMOTE_SUBMIX, + AudioDeviceInfo.TYPE_TELEPHONY, + 28 // TODO: 为这个值定义一个有意义的常量名,例如 TYPE_CUSTOM_DEVICE + ) + + /** + * 当新的音频设备被添加(连接)时调用。 + */ @SuppressLint("MissingPermission") override fun onAudioDevicesAdded(addedDevices: Array?) { - - val connectedTime = System.currentTimeMillis() - var deviceAdded = false + if (addedDevices.isNullOrEmpty()) return serviceScope.launch { - - addedDevices?.forEach { deviceInfo -> - if (deviceInfo.type in listOf( - AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, - AudioDeviceInfo.TYPE_BUILTIN_MIC, - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, - AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE, - AudioDeviceInfo.TYPE_FM_TUNER, - AudioDeviceInfo.TYPE_REMOTE_SUBMIX, - AudioDeviceInfo.TYPE_TELEPHONY, - 28, // Consider defining this constant if it's meaningful - ) - ) { - return@forEach - } - - val deviceName = deviceInfo.productName?.toString()?.trim() - ?: "Unknown Device ${deviceInfo.id}" // Handle null productName - if (deviceName == Build.MODEL || deviceName.isBlank()) return@forEach // Skip self or blank names - - Log.v("MUTE_DEVICE", "Device Added: $deviceName (Type: ${deviceInfo.type})") - - deviceMapMutex.withLock { - // Add to instance map - if (!deviceMap.containsKey(deviceName)) { - deviceMap[deviceName] = Connection( - name = deviceName, - type = deviceInfo.type, - connectedTime = connectedTime, - disconnectedTime = null, - duration = null, - date = LocalDate.now().toString() - ) - deviceAdded = true - - // Start timer only if watching is enabled - if (preferenceRepository.getWatchingState().first()) { - Log.v("MUTE_DEVICEMAP", "Starting timer for $deviceName") - val deviceTimer = DeviceTimer( - context = applicationContext, - deviceName = deviceName - ) - deviceTimer.start() - deviceTimerMap[deviceName] = deviceTimer - } - } - } - - // Ear Protection Logic - if (preferenceRepository.isEarProtectionOn().first()) { - val protectionJob = serviceScope.launch { - try { - var boolProtected = false - // Use loop with counter or check to prevent infinite loop if volume doesn't change - var attempts = 100 // Limit attempts - while (getVolumePercentage() > 25 && attempts-- > 0) { - ensureActive() - boolProtected = true - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_LOWER, - 0 - ) - } - attempts = 100 // Reset attempts - while (getVolumePercentage() < 10 && attempts-- > 0) { - ensureActive() - boolProtected = true - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_RAISE, - 0 - ) - } - if (boolProtected) { - Log.d( - "ForegroundService", - "Ear protection applied for $deviceName" - ) - NotificationManagerCompat.from(applicationContext).apply { - notify( - ID_NOTIFICATION_PROTECT, - createProtectionNotification() - ) - } - } - - } catch (e: kotlinx.coroutines.CancellationException) { - // 协程被取消时会进入这里 - Log.d("ProtectionLogic", "Protection for ${deviceInfo.address} was cancelled.") - } finally { - // 任务完成后(无论成功或取消),从 Map 中移除 - protectionJobs.remove(deviceInfo.address) - } + // 在循环外一次性获取配置,避免重复读取 + val isWatchingEnabled = preferenceRepository.getWatchingState().first() + val isEarProtectionOn = preferenceRepository.isEarProtectionOn().first() + var hasChanges = false // 标志位,用于判断是否需要更新UI + + addedDevices.forEach { deviceInfo -> + // 使用辅助函数进行判断,使逻辑更清晰 + if (isIgnoredDevice(deviceInfo)) return@forEach + + val deviceName = getDeviceName(deviceInfo) ?: return@forEach + if (deviceName == Build.MODEL) return@forEach // 忽略本机设备 + + // 处理设备添加的核心逻辑 + val wasAdded = processNewDevice(deviceInfo, deviceName, isWatchingEnabled) + if (wasAdded) { + hasChanges = true + // 如果开启了护耳模式,则应用 + if (isEarProtectionOn) { + applyEarProtection(deviceName) } - - protectionJobs[deviceInfo.address] = protectionJob } + } - if (deviceAdded) { - Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") - // Broadcast the updated list - broadcastConnectionsUpdate() - // Update the foreground notification - NotificationManagerCompat.from(applicationContext).apply { - notify( - ID_NOTIFICATION_FOREGROUND, - createForegroundNotification(applicationContext) - ) - } - } + // 在所有设备处理完毕后,如果发生了变化,则统一更新UI和通知 + if (hasChanges) { + Log.d(TAG, "Device map updated due to additions: ${deviceMap.keys}") + updateUiAndNotifications() } } - } + /** + * 当音频设备被移除(断开)时调用。 + */ @SuppressLint("MissingPermission") override fun onAudioDevicesRemoved(removedDevices: Array?) { - var deviceRemoved = false + if (removedDevices.isNullOrEmpty()) return serviceScope.launch { + var hasChanges = false + + removedDevices.forEach { deviceInfo -> + val deviceName = getDeviceName(deviceInfo) ?: return@forEach - removedDevices?.forEach { deviceInfo -> - val deviceName = deviceInfo.productName?.toString()?.trim() - ?: return@forEach // Skip if no name - val disconnectedTime = System.currentTimeMillis() - var connectionsToSave: Connection? = null + // 取消与该设备相关的护耳任务 + protectionJobs.remove(deviceName)?.cancel() - protectionJobs[deviceInfo.address]?.let { - Log.d("ProtectionLogic", "Device ${deviceInfo.address} disconnected. Cancelling job.") - it.cancel() + // 处理设备移除的核心逻辑 + val connectionToSave = processRemovedDevice(deviceName) + if (connectionToSave != null) { + hasChanges = true + saveConnectionToDatabase(connectionToSave) } + } - deviceMapMutex.withLock { - if (deviceMap.containsKey(deviceName)) { - deviceRemoved = true - val connection = - deviceMap.remove(deviceName) // Remove from instance map - deviceTimerMap.remove(deviceName)?.stop() + // 在所有设备处理完毕后,如果发生了变化,则统一更新UI和通知 + if (hasChanges) { + Log.d(TAG, "Device map updated due to removals: ${deviceMap.keys}") + updateUiAndNotifications() + } + } + } - Log.v( - "MUTE_DEVICE", - "Device Removed: $deviceName (Type: ${deviceInfo.type})" - ) + // --- 辅助函数 --- - if (connection?.connectedTime != null) { - val connectionTime = disconnectedTime - connection.connectedTime - - connectionsToSave = Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, - disconnectedTime = disconnectedTime, - duration = connectionTime, - date = connection.date - ) - - // Cancel alert notification if connection was long - if (connectionTime > ALERT_TIME) { - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_ALERT) - } - - } - - // Stop and remove timer if watching is enabled - if (preferenceRepository.getWatchingState().first()) { - deviceTimerMap.remove(deviceName)?.let { - Log.v("MUTE_DEVICEMAP", "Stopping timer for $deviceName") - it.stop() - } - } - } + /** + * 检查设备类型是否应该被忽略。 + */ + private fun isIgnoredDevice(deviceInfo: AudioDeviceInfo): Boolean { + return deviceInfo.type in IGNORED_DEVICE_TYPES + } + + /** + * 获取规范化的设备名称。 + */ + private fun getDeviceName(deviceInfo: AudioDeviceInfo): String? { + val name = deviceInfo.productName?.toString()?.trim() + return if (name.isNullOrBlank()) null else name + } + + /** + * 处理新连接的设备,更新状态并返回是否成功添加。 + */ + private suspend fun processNewDevice( + deviceInfo: AudioDeviceInfo, + deviceName: String, + isWatching: Boolean + ): Boolean { + var wasAdded = false + deviceMapMutex.withLock { + if (!deviceMap.containsKey(deviceName)) { + Log.d(TAG, "Device Added: $deviceName (Type: ${deviceInfo.type})") + deviceMap[deviceName] = Connection( + name = deviceName, + type = deviceInfo.type, + connectedTime = System.currentTimeMillis(), + disconnectedTime = null, + duration = null, + date = LocalDate.now().toString() + ) + wasAdded = true + + if (isWatching) { + Log.d(TAG, "Starting timer for $deviceName") + val deviceTimer = DeviceTimer(applicationContext, deviceName) + deviceTimer.start() + deviceTimerMap[deviceName] = deviceTimer } + } + } + return wasAdded + } - connectionsToSave?.let { conn -> - try { - connectionDao.insert(conn) - Log.d( - "ForegroundService", - "Saved connection data for removed device ${conn.name}" - ) - } catch (e: Exception) { - Log.e( - "ForegroundService", - "Error saving connection data for ${conn.name}", - e - ) + /** + * 处理断开连接的设备,返回一个需要被保存到数据库的 Connection 对象。 + */ + private suspend fun processRemovedDevice(deviceName: String): Connection? { + var connectionToSave: Connection? = null + val disconnectedTime = System.currentTimeMillis() + + deviceMapMutex.withLock { + // 如果设备在我们的监控列表中,则处理它 + if (deviceMap.containsKey(deviceName)) { + Log.d(TAG, "Device Removed: $deviceName") + + val connection = deviceMap.remove(deviceName) + deviceTimerMap.remove(deviceName)?.stop() // 停止并移除计时器 + + if (connection?.connectedTime != null) { + val duration = disconnectedTime - connection.connectedTime + connectionToSave = connection.copy( + disconnectedTime = disconnectedTime, + duration = duration + ) + + // 如果连接时间超过阈值,取消警报通知 + if (duration > ALERT_TIME) { + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(ID_NOTIFICATION_ALERT) } + } + } + } + return connectionToSave + } + + /** + * 将连接记录保存到数据库。 + */ + private suspend fun saveConnectionToDatabase(connection: Connection) { + try { + connectionDao.insert(connection) + Log.d(TAG, "Saved connection data for ${connection.name}") + } catch (e: Exception) { + Log.e(TAG, "Error saving connection data for ${connection.name}", e) + } + } + /** + * 应用护耳逻辑,自动调整音量到安全范围。 + */ + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun applyEarProtection(deviceName: String) { + val protectionJob = serviceScope.launch { + try { + Log.d(TAG, "Applying ear protection for $deviceName") + var protectionApplied = false + + // 降低音量 + var lowerAttempts = VOLUME_ADJUST_ATTEMPTS + while (getVolumePercentage() > EAR_PROTECTION_UPPER_THRESHOLD && lowerAttempts-- > 0 && isActive) { + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_LOWER, + 0 + ) + protectionApplied = true } - if (deviceRemoved) { - Log.v("MUTE_MAP", "Device map updated: ${deviceMap.keys}") - // Broadcast the updated list - broadcastConnectionsUpdate() - // Update the foreground notification - NotificationManagerCompat.from(applicationContext).apply { - notify( - ID_NOTIFICATION_FOREGROUND, - createForegroundNotification(applicationContext) - ) - } - deviceRemoved = false + // 升高音量 + var raiseAttempts = VOLUME_ADJUST_ATTEMPTS + while (getVolumePercentage() < EAR_PROTECTION_LOWER_THRESHOLD && raiseAttempts-- > 0 && isActive) { + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_RAISE, + 0 + ) + protectionApplied = true } + if (protectionApplied) { + Log.d(TAG, "Ear protection applied for $deviceName.") + NotificationManagerCompat.from(applicationContext) + .notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + } + } catch (e: kotlinx.coroutines.CancellationException) { + Log.d(TAG, "Protection job for $deviceName was cancelled.") + } finally { + // 任务完成后(无论成功或取消),从 Map 中移除 + protectionJobs.remove(deviceName) } } + // 使用设备名称作为 Key,更稳定 + protectionJobs[deviceName] = protectionJob + } - + /** + * 统一更新前台通知和广播。 + */ + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun updateUiAndNotifications() { + // 广播更新 + broadcastConnectionsUpdate() + // 更新前台服务通知 + NotificationManagerCompat.from(applicationContext) + .notify( + ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(applicationContext) + ) } } From adb83f0525e265ac1ccebb3d60247bc5cc111210 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 18:44:26 +0800 Subject: [PATCH 14/48] Refactor Application class and update manifest - Renamed `ConnectionsApplication` to `LiveInPeaceApplication`. - Moved database and repository initialization from the deleted `ConnectionsApplication` to `LiveInPeaceApplication`. - Updated `AndroidManifest.xml` to reflect the new application class name. - Updated `HistoryActivity` to use `LiveInPeaceApplication` for accessing the repository. - Moved `DynamicColors.applyToActivitiesIfAvailable(this)` call to `LiveInPeaceApplication`. --- app/src/main/AndroidManifest.xml | 2 +- .../liveinpeace/ConnectionsApplication.kt | 19 ------------------- .../com/maary/liveinpeace/HistoryActivity.kt | 2 +- .../liveinpeace/LiveInPeaceApplication.kt | 7 +++++++ 4 files changed, 9 insertions(+), 21 deletions(-) delete mode 100644 app/src/main/java/com/maary/liveinpeace/ConnectionsApplication.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0316bcc..d2d1b49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ Date: Sat, 14 Jun 2025 19:58:24 +0800 Subject: [PATCH 15/48] Refactor ForegroundService for clarity and robustness This commit refactors the `ForegroundService` to improve its structure, readability, and reliability. Key changes include: - **Encapsulation and Cohesion**: Related logic, such as permission checks and notification updates, is now encapsulated in helper functions. - **Constant Usage**: Magic numbers (e.g., PendingIntent request codes, device type IDs) have been replaced with meaningful constants. `TYPE_UNKNOWN_DEVICE_28` is introduced for the previously unnamed device type. - **Code Simplification**: Broadcast receiver implementations are streamlined. - **Robustness**: Unified permission checks are added before all notification operations to ensure stability on Android 13+. - **Readability**: Function and variable names are improved, and comments are added for clarity. - **Structural Optimization**: The `audioDeviceCallback` logic has been broken down into smaller, single-responsibility functions, reducing complexity. - **Resource Management**: Implemented safer unregistering of receivers and callbacks. - **Notification Logic**: Notification creation and icon generation logic is clarified and centralized. - **Coroutine Scope**: Uses `SupervisorJob` for the service scope to prevent one job's failure from canceling others. - **Error Handling**: Improved logging for errors during resource cleanup and data saving. --- .../liveinpeace/service/ForegroundService.kt | 776 ++++++++---------- 1 file changed, 335 insertions(+), 441 deletions(-) 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 8c6bf9b..2ed6cd5 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -16,31 +16,15 @@ import android.media.AudioManager import android.os.Build import android.os.IBinder import android.util.Log -import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat -import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ALERT_TIME -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_CONNECTIONS_UPDATE -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_FOREGROUND -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_MUTE -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_TOGGLE -import com.maary.liveinpeace.Constants.Companion.BROADCAST_ACTION_SLEEPTIMER_UPDATE -import com.maary.liveinpeace.Constants.Companion.BROADCAST_FOREGROUND_INTENT_EXTRA -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_DEFAULT -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_PROTECT -import com.maary.liveinpeace.Constants.Companion.EXTRA_CONNECTIONS_LIST -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_ALERT -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_FOREGROUND -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_FORE -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_PROTECT -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_PROTECT +import com.maary.liveinpeace.Constants import com.maary.liveinpeace.DeviceTimer import com.maary.liveinpeace.MainActivity import com.maary.liveinpeace.R +import com.maary.liveinpeace.SleepNotification import com.maary.liveinpeace.SleepNotification.find import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.database.ConnectionDao @@ -51,146 +35,193 @@ import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver import dagger.hilt.android.AndroidEntryPoint import jakarta.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel +import kotlinx.coroutines.* import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.text.DateFormat import java.time.LocalDate -import java.util.Date +import java.util.* import java.util.concurrent.ConcurrentHashMap +/** + * 前台服务,用于监控音频设备连接、实现护耳模式和管理通知。 + * + * 重构要点: + * 1. **封装和内聚**: 将相关逻辑(如权限检查、通知更新)封装到独立的辅助函数中,提高代码复用性。 + * 2. **常量化**: 消除魔法数字(如 PendingIntent 的请求码、设备类型 ID),代之以有意义的常量,增强可读性。 + * 3. **代码简化**: 简化广播接收器的实现,避免代码重复。 + * 4. **健壮性**: 在所有通知操作前添加统一的权限检查,确保在 Android 13+ 系统上的稳定性。 + * 5. **可读性**: 优化函数和变量命名,增加注释,使代码意图更清晰。 + * 6. **结构优化**: `audioDeviceCallback` 内部逻辑被拆分为更小、职责更单一的函数,降低了复杂度和嵌套。 + */ @AndroidEntryPoint class ForegroundService : Service() { - // Use instance scope for CoroutineScope to easily cancel jobs in onDestroy - private val serviceScope = CoroutineScope(Dispatchers.IO) + companion object { + private const val TAG = "ForegroundService" - private lateinit var database: ConnectionRoomDatabase - private lateinit var connectionDao: ConnectionDao - private lateinit var audioManager: AudioManager + // 为 PendingIntent 定义请求码常量,避免使用魔法数字 + private const val REQUEST_CODE_SETTINGS = 0 + private const val REQUEST_CODE_SLEEP_TIMER = 2 + private const val REQUEST_CODE_MUTE = 3 + + // 为未知的设备类型 `28` 定义一个有意义的常量名 + private const val TYPE_UNKNOWN_DEVICE_28 = 28 + } + + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val audioManager: AudioManager by lazy { + getSystemService(Context.AUDIO_SERVICE) as AudioManager + } @Inject lateinit var preferenceRepository: PreferenceRepository - // Instance variable for device map - private val deviceMap: MutableMap = - ConcurrentHashMap() // Use ConcurrentHashMap if worried about potential multi-threaded access, otherwise regular HashMap is fine. - private val deviceTimerMap: MutableMap = ConcurrentHashMap() + private lateinit var connectionDao: ConnectionDao + private lateinit var volumeComment: Array - // create mutex instance to protect deviceMap + // 使用 ConcurrentHashMap 保证多线程访问的安全性 + private val deviceMap = ConcurrentHashMap() + private val deviceTimerMap = ConcurrentHashMap() + private val protectionJobs = ConcurrentHashMap() private val deviceMapMutex = Mutex() - private val protectionJobs = ConcurrentHashMap() + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service creating...") - private val volumeDrawableIds = intArrayOf( - R.drawable.ic_volume_silent, - R.drawable.ic_volume_low, - R.drawable.ic_volume_middle, - R.drawable.ic_volume_high, - R.drawable.ic_volume_mega - ) + initializeDependencies() + registerReceiversAndCallbacks() - private lateinit var volumeComment: Array + // 启动前台服务,并立即更新一次通知状态 + startForegroundWithNotification() + setServiceRunningState(true) - // Method to broadcast the current connection list - private fun broadcastConnectionsUpdate() { - val intent = Intent(BROADCAST_ACTION_CONNECTIONS_UPDATE) - // Convert map values to ArrayList which is Serializable/Parcelable - val connectionList = ArrayList(deviceMap.values) - intent.putParcelableArrayListExtra(EXTRA_CONNECTIONS_LIST, connectionList) - sendBroadcast(intent) + Log.d(TAG, "Service created successfully.") } + private fun initializeDependencies() { + connectionDao = ConnectionRoomDatabase.getDatabase(applicationContext).connectionDao() + volumeComment = resources.getStringArray(R.array.array_volume_comment) + } - private fun getVolumePercentage(): Int { - val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - // Avoid division by zero if maxVolume is 0 - return if (maxVolume > 0) 100 * currentVolume / maxVolume else 0 + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private fun registerReceiversAndCallbacks() { + // 注册音频设备回调 + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + + // 注册音量变化接收器 + val volumeFilter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") + registerReceiver(volumeChangeReceiver, volumeFilter) + + // 注册休眠定时器更新接收器 + val sleepFilter = IntentFilter(Constants.BROADCAST_ACTION_SLEEPTIMER_UPDATE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(sleepReceiver, sleepFilter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(sleepReceiver, sleepFilter) + } } - private fun getVolumeLevel(percent: Int): Int { - return when (percent) { - in 0..0 -> 0 - in 1..25 -> 1 - in 26..50 -> 2 - in 50..80 -> 3 - else -> 4 + @SuppressLint("MissingPermission") + private fun startForegroundWithNotification() { + if (!hasNotificationPermission()) { + Log.w(TAG, "Missing POST_NOTIFICATIONS permission. Cannot start foreground service with notification.") + // 即使没有权限,也需要调用 startForeground,否则服务可能被系统杀死 + // 可以提供一个不含任何信息的最小化 Notification + startForeground(Constants.ID_NOTIFICATION_FOREGROUND, NotificationCompat.Builder(this, Constants.CHANNEL_ID_DEFAULT).build()) + return } + startForeground(Constants.ID_NOTIFICATION_FOREGROUND, createForegroundNotification(this)) } - private val volumeChangeReceiver = object : VolumeReceiver() { - @SuppressLint("MissingPermission") - override fun updateNotification(context: Context) { - Log.v("MUTE_TEST", "VOLUME_CHANGE_RECEIVER") - with(NotificationManagerCompat.from(applicationContext)) { - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand received.") + // 确保服务被重新创建时,通知内容是最新的 + updateForegroundNotification() + return START_STICKY + } + + override fun onDestroy() { + Log.d(TAG, "Service destroying...") + + // 在清理资源前,先更新服务状态 + setServiceRunningState(false) + + saveDataForActiveConnections() + cleanupResources() + + // 停止前台服务并移除通知 + stopForeground(STOP_FOREGROUND_REMOVE) + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(Constants.ID_NOTIFICATION_FOREGROUND) + + Log.d(TAG, "Service destroyed.") + super.onDestroy() + } + + private fun cleanupResources() { + // 取消所有由该服务启动的协程 + serviceScope.cancel() + + // 停止并清理所有设备计时器 + deviceTimerMap.values.forEach { it.stop() } + deviceTimerMap.clear() + + // 安全地反注册所有接收器和回调 + safeUnregisterReceiver(volumeChangeReceiver) + safeUnregisterReceiver(sleepReceiver) + try { + audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) + } catch (e: Exception) { + Log.e(TAG, "Error unregistering audio callback", e) } } - private val sleepReceiver = object : SleepReceiver() { - @SuppressLint("MissingPermission") - override fun updateNotification(context: Context) { - with(NotificationManagerCompat.from(applicationContext)) { - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } + private fun safeUnregisterReceiver(receiver: android.content.BroadcastReceiver) { + try { + unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "${receiver::class.java.simpleName} was not registered or already unregistered.", e) } } - // Saves data for currently connected devices when service stops private fun saveDataForActiveConnections() { val disconnectedTime = System.currentTimeMillis() - val currentConnections = - deviceMap.toMap() // Create a copy to avoid ConcurrentModificationException if needed - deviceMap.clear() // Clear the instance map + val currentConnections = deviceMap.toMap() // 创建副本以安全遍历 + deviceMap.clear() currentConnections.forEach { (_, connection) -> - val connectedTime = connection.connectedTime - if (connectedTime != null) { - val connectionTime = disconnectedTime - connectedTime - serviceScope.launch { - try { - connectionDao.insert( - Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, - disconnectedTime = disconnectedTime, - duration = connectionTime, - date = connection.date - ) - ) - Log.d("ForegroundService", "Saved connection data for ${connection.name}") - } catch (e: Exception) { - Log.e( - "ForegroundService", - "Error saving connection data for ${connection.name}", - e - ) - } + val connectedTime = connection.connectedTime ?: return@forEach + val connectionTime = disconnectedTime - connectedTime + + serviceScope.launch { + try { + val finalConnection = connection.copy( + disconnectedTime = disconnectedTime, + duration = connectionTime + ) + connectionDao.insert(finalConnection) + Log.d(TAG, "Saved connection data for ${connection.name}") + } catch (e: Exception) { + Log.e(TAG, "Error saving connection data for ${connection.name}", e) } } } - // Notify that the connection list is now empty broadcastConnectionsUpdate() } + /** + * 音频设备连接状态的回调处理。 + * 内部逻辑被拆分为多个辅助函数,以提高清晰度和可维护性。 + */ private val audioDeviceCallback = object : AudioDeviceCallback() { - - // 使用伴生对象管理常量,消除魔法数字 - private val TAG = "AudioDeviceCallback" + private val CALLBACK_TAG = "AudioDeviceCallback" private val EAR_PROTECTION_LOWER_THRESHOLD = 10 private val EAR_PROTECTION_UPPER_THRESHOLD = 25 private val VOLUME_ADJUST_ATTEMPTS = 100 - // 将需要忽略的设备类型定义为常量集合,方便管理 private val IGNORED_DEVICE_TYPES = setOf( AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, AudioDeviceInfo.TYPE_BUILTIN_MIC, @@ -199,100 +230,51 @@ class ForegroundService : Service() { AudioDeviceInfo.TYPE_FM_TUNER, AudioDeviceInfo.TYPE_REMOTE_SUBMIX, AudioDeviceInfo.TYPE_TELEPHONY, - 28 // TODO: 为这个值定义一个有意义的常量名,例如 TYPE_CUSTOM_DEVICE + TYPE_UNKNOWN_DEVICE_28 // 使用常量代替魔法数字 ) - /** - * 当新的音频设备被添加(连接)时调用。 - */ - @SuppressLint("MissingPermission") override fun onAudioDevicesAdded(addedDevices: Array?) { if (addedDevices.isNullOrEmpty()) return serviceScope.launch { - // 在循环外一次性获取配置,避免重复读取 val isWatchingEnabled = preferenceRepository.getWatchingState().first() val isEarProtectionOn = preferenceRepository.isEarProtectionOn().first() - var hasChanges = false // 标志位,用于判断是否需要更新UI - - addedDevices.forEach { deviceInfo -> - // 使用辅助函数进行判断,使逻辑更清晰 - if (isIgnoredDevice(deviceInfo)) return@forEach - val deviceName = getDeviceName(deviceInfo) ?: return@forEach - if (deviceName == Build.MODEL) return@forEach // 忽略本机设备 - - // 处理设备添加的核心逻辑 + addedDevices.filterNot { shouldIgnoreDevice(it) }.forEach { deviceInfo -> + val deviceName = getDeviceName(deviceInfo) val wasAdded = processNewDevice(deviceInfo, deviceName, isWatchingEnabled) - if (wasAdded) { - hasChanges = true - // 如果开启了护耳模式,则应用 - if (isEarProtectionOn) { - applyEarProtection(deviceName) - } + if (wasAdded && isEarProtectionOn) { + applyEarProtection(deviceName) } } - - // 在所有设备处理完毕后,如果发生了变化,则统一更新UI和通知 - if (hasChanges) { - Log.d(TAG, "Device map updated due to additions: ${deviceMap.keys}") - updateUiAndNotifications() - } + onDeviceListChanged() } } - /** - * 当音频设备被移除(断开)时调用。 - */ - @SuppressLint("MissingPermission") override fun onAudioDevicesRemoved(removedDevices: Array?) { if (removedDevices.isNullOrEmpty()) return serviceScope.launch { - var hasChanges = false - - removedDevices.forEach { deviceInfo -> - val deviceName = getDeviceName(deviceInfo) ?: return@forEach - - // 取消与该设备相关的护耳任务 - protectionJobs.remove(deviceName)?.cancel() - - // 处理设备移除的核心逻辑 - val connectionToSave = processRemovedDevice(deviceName) - if (connectionToSave != null) { - hasChanges = true + removedDevices.filterNot { shouldIgnoreDevice(it) }.forEach { deviceInfo -> + val deviceName = getDeviceName(deviceInfo) + processRemovedDevice(deviceName)?.let { connectionToSave -> saveConnectionToDatabase(connectionToSave) } } - - // 在所有设备处理完毕后,如果发生了变化,则统一更新UI和通知 - if (hasChanges) { - Log.d(TAG, "Device map updated due to removals: ${deviceMap.keys}") - updateUiAndNotifications() - } + onDeviceListChanged() } } - // --- 辅助函数 --- - - /** - * 检查设备类型是否应该被忽略。 - */ - private fun isIgnoredDevice(deviceInfo: AudioDeviceInfo): Boolean { - return deviceInfo.type in IGNORED_DEVICE_TYPES + private fun shouldIgnoreDevice(deviceInfo: AudioDeviceInfo): Boolean { + val deviceName = getDeviceName(deviceInfo) + // 忽略本机、无名设备或特定类型的设备 + return deviceName.isEmpty() || deviceName == Build.MODEL || deviceInfo.type in IGNORED_DEVICE_TYPES } - /** - * 获取规范化的设备名称。 - */ - private fun getDeviceName(deviceInfo: AudioDeviceInfo): String? { - val name = deviceInfo.productName?.toString()?.trim() - return if (name.isNullOrBlank()) null else name + private fun getDeviceName(deviceInfo: AudioDeviceInfo): String { + return deviceInfo.productName?.toString()?.trim() ?: "" } - /** - * 处理新连接的设备,更新状态并返回是否成功添加。 - */ private suspend fun processNewDevice( deviceInfo: AudioDeviceInfo, deviceName: String, @@ -301,7 +283,7 @@ class ForegroundService : Service() { var wasAdded = false deviceMapMutex.withLock { if (!deviceMap.containsKey(deviceName)) { - Log.d(TAG, "Device Added: $deviceName (Type: ${deviceInfo.type})") + Log.d(CALLBACK_TAG, "Device Added: $deviceName") deviceMap[deviceName] = Connection( name = deviceName, type = deviceInfo.type, @@ -313,43 +295,38 @@ class ForegroundService : Service() { wasAdded = true if (isWatching) { - Log.d(TAG, "Starting timer for $deviceName") - val deviceTimer = DeviceTimer(applicationContext, deviceName) - deviceTimer.start() - deviceTimerMap[deviceName] = deviceTimer + Log.d(CALLBACK_TAG, "Starting timer for $deviceName") + DeviceTimer(applicationContext, deviceName).also { + it.start() + deviceTimerMap[deviceName] = it + } } } } return wasAdded } - /** - * 处理断开连接的设备,返回一个需要被保存到数据库的 Connection 对象。 - */ private suspend fun processRemovedDevice(deviceName: String): Connection? { var connectionToSave: Connection? = null - val disconnectedTime = System.currentTimeMillis() - deviceMapMutex.withLock { - // 如果设备在我们的监控列表中,则处理它 if (deviceMap.containsKey(deviceName)) { - Log.d(TAG, "Device Removed: $deviceName") + Log.d(CALLBACK_TAG, "Device Removed: $deviceName") - val connection = deviceMap.remove(deviceName) - deviceTimerMap.remove(deviceName)?.stop() // 停止并移除计时器 + // 停止相关任务和计时器 + protectionJobs.remove(deviceName)?.cancel() + deviceTimerMap.remove(deviceName)?.stop() + val connection = deviceMap.remove(deviceName) if (connection?.connectedTime != null) { + val disconnectedTime = System.currentTimeMillis() val duration = disconnectedTime - connection.connectedTime connectionToSave = connection.copy( disconnectedTime = disconnectedTime, duration = duration ) - - // 如果连接时间超过阈值,取消警报通知 - if (duration > ALERT_TIME) { - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_ALERT) + if (duration > Constants.ALERT_TIME) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(Constants.ID_NOTIFICATION_ALERT) } } } @@ -357,322 +334,239 @@ class ForegroundService : Service() { return connectionToSave } - /** - * 将连接记录保存到数据库。 - */ private suspend fun saveConnectionToDatabase(connection: Connection) { try { connectionDao.insert(connection) - Log.d(TAG, "Saved connection data for ${connection.name}") + Log.d(CALLBACK_TAG, "Saved connection data for ${connection.name}") } catch (e: Exception) { - Log.e(TAG, "Error saving connection data for ${connection.name}", e) + Log.e(CALLBACK_TAG, "Error saving connection data", e) } } - /** - * 应用护耳逻辑,自动调整音量到安全范围。 - */ - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private fun applyEarProtection(deviceName: String) { val protectionJob = serviceScope.launch { try { - Log.d(TAG, "Applying ear protection for $deviceName") + Log.d(CALLBACK_TAG, "Applying ear protection for $deviceName") var protectionApplied = false - // 降低音量 - var lowerAttempts = VOLUME_ADJUST_ATTEMPTS - while (getVolumePercentage() > EAR_PROTECTION_UPPER_THRESHOLD && lowerAttempts-- > 0 && isActive) { - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_LOWER, - 0 - ) + // 调整音量到安全范围 + while (getVolumePercentage() > EAR_PROTECTION_UPPER_THRESHOLD && isActive) { + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) protectionApplied = true } - - // 升高音量 - var raiseAttempts = VOLUME_ADJUST_ATTEMPTS - while (getVolumePercentage() < EAR_PROTECTION_LOWER_THRESHOLD && raiseAttempts-- > 0 && isActive) { - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_RAISE, - 0 - ) + while (getVolumePercentage() < EAR_PROTECTION_LOWER_THRESHOLD && isActive) { + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) protectionApplied = true } if (protectionApplied) { - Log.d(TAG, "Ear protection applied for $deviceName.") - NotificationManagerCompat.from(applicationContext) - .notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) + Log.d(CALLBACK_TAG, "Ear protection applied for $deviceName.") + showProtectionNotification() } - } catch (e: kotlinx.coroutines.CancellationException) { - Log.d(TAG, "Protection job for $deviceName was cancelled.") + } catch (e: CancellationException) { + Log.d(CALLBACK_TAG, "Protection job for $deviceName was cancelled.") } finally { - // 任务完成后(无论成功或取消),从 Map 中移除 protectionJobs.remove(deviceName) } } - // 使用设备名称作为 Key,更稳定 protectionJobs[deviceName] = protectionJob } - - /** - * 统一更新前台通知和广播。 - */ - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) - private fun updateUiAndNotifications() { - // 广播更新 - broadcastConnectionsUpdate() - // 更新前台服务通知 - NotificationManagerCompat.from(applicationContext) - .notify( - ID_NOTIFICATION_FOREGROUND, - createForegroundNotification(applicationContext) - ) - } } + // --- 辅助函数 --- - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - override fun onCreate() { - super.onCreate() - - audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.registerAudioDeviceCallback( - audioDeviceCallback, - null - ) // Consider using a Handler for the callback thread if needed - - // Initialize Database and DAO - database = ConnectionRoomDatabase.getDatabase(applicationContext) - connectionDao = database.connectionDao() - - // Load comments - volumeComment = resources.getStringArray(R.array.array_volume_comment) - - // Register Receivers - val volumeFilter = IntentFilter().apply { - addAction("android.media.VOLUME_CHANGED_ACTION") - } // Use constant - // Check for permission before registering if targeting Android 14+ for non-exported receivers needing permissions - registerReceiver(volumeChangeReceiver, volumeFilter) - - val sleepFilter = IntentFilter(BROADCAST_ACTION_SLEEPTIMER_UPDATE) - // Use RECEIVER_NOT_EXPORTED for internal broadcasts - registerReceiver(sleepReceiver, sleepFilter, RECEIVER_NOT_EXPORTED) - - // Start foreground - startForeground( - ID_NOTIFICATION_FOREGROUND, - createForegroundNotification(applicationContext) - ) - - // Update persisted state and notify components - setServiceRunningState(true) + override fun onBind(intent: Intent?): IBinder? = null - Log.d("ForegroundService", "onCreate Finished") + /** + * 封装权限检查逻辑,提高代码复用性。 + */ + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true + } + return ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d("ForegroundService", "onStartCommand received") - - // Ensure the foreground notification is up-to-date if started again - NotificationManagerCompat.from(applicationContext).apply { - if (ActivityCompat.checkSelfPermission( - applicationContext, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) { - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) - } + /** + * 统一更新前台服务通知的入口。 + */ + @SuppressLint("MissingPermission") + private fun updateForegroundNotification() { + if (!hasNotificationPermission()) { + Log.w(TAG, "Cannot update notification: Permission denied.") + return } - return START_STICKY + NotificationManagerCompat.from(this).notify( + Constants.ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(this) + ) } - override fun onDestroy() { - Log.d("ForegroundService", "onDestroy - Cleaning up...") - - // Set persisted state to false *before* cleanup, in case cleanup fails - setServiceRunningState(false) - - // Save data for any remaining active connections - saveDataForActiveConnections() + /** + * 显示护耳模式已应用的通知。 + */ + @SuppressLint("MissingPermission") + private fun showProtectionNotification() { + if (!hasNotificationPermission()) return + val notification = NotificationCompat.Builder(this, Constants.CHANNEL_ID_PROTECT) + .setContentTitle(getString(R.string.ears_protected)) + .setSmallIcon(R.drawable.ic_headphones_protection) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setGroup(Constants.ID_NOTIFICATION_GROUP_PROTECT) + .setTimeoutAfter(3000) + .build() + NotificationManagerCompat.from(this).notify(Constants.ID_NOTIFICATION_PROTECT, notification) + } - // Cancel all coroutines started by this service instance - serviceScope.cancel() + private fun onDeviceListChanged() { + broadcastConnectionsUpdate() + updateForegroundNotification() + } - // Stop and clear all timers - try { - deviceTimerMap.values.forEach { it.stop() } - deviceTimerMap.clear() - Log.d("ForegroundService", "Device timers stopped and cleared.") - } catch (e: Exception) { - Log.e("ForegroundService", "Error stopping timers", e) + private fun broadcastConnectionsUpdate() { + val intent = Intent(Constants.BROADCAST_ACTION_CONNECTIONS_UPDATE).apply { + putParcelableArrayListExtra( + Constants.EXTRA_CONNECTIONS_LIST, + ArrayList(deviceMap.values) + ) } + sendBroadcast(intent) + } - - // Unregister receivers and callbacks - try { - unregisterReceiver(volumeChangeReceiver) - Log.d("ForegroundService", "Volume receiver unregistered.") - } catch (e: IllegalArgumentException) { - Log.w("ForegroundService", "Volume receiver already unregistered?", e) - } - try { - unregisterReceiver(sleepReceiver) - Log.d("ForegroundService", "Sleep receiver unregistered.") - } catch (e: IllegalArgumentException) { - Log.w("ForegroundService", "Sleep receiver already unregistered?", e) + private fun setServiceRunningState(isRunning: Boolean) { + serviceScope.launch { + preferenceRepository.setServiceRunning(isRunning) } - try { - audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) - Log.d("ForegroundService", "Audio device callback unregistered.") - } catch (e: Exception) { - Log.e("ForegroundService", "Error unregistering audio callback", e) + val intent = Intent(Constants.BROADCAST_ACTION_FOREGROUND).apply { + putExtra(Constants.BROADCAST_FOREGROUND_INTENT_EXTRA, isRunning) } + sendBroadcast(intent) + Log.d(TAG, "Service running state set to $isRunning and broadcast sent.") + } - // Stop foreground service removal notification - stopForeground(STOP_FOREGROUND_REMOVE) + private fun getVolumePercentage(): Int { + val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + return if (maxVolume > 0) 100 * currentVolume / maxVolume else 0 + } - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_FOREGROUND) - Log.d("ForegroundService", "onDestroy Finished") - super.onDestroy() + private fun getVolumeLevel(percent: Int): Int { + return when (percent) { + 0 -> 0 + in 1..25 -> 1 + in 26..50 -> 2 + in 51..80 -> 3 + else -> 4 + } } + // --- 广播接收器 --- - // Required method for Service, return null for non-bound service - override fun onBind(intent: Intent?): IBinder? { - return null + private val volumeChangeReceiver = object : VolumeReceiver() { + override fun updateNotification(context: Context) { + updateForegroundNotification() + } } - // Helper to update persisted state and send broadcast - private fun setServiceRunningState(isRunning: Boolean) { - // Update Preferences - serviceScope.launch { - preferenceRepository.setServiceRunning(isRunning) + private val sleepReceiver = object : SleepReceiver() { + override fun updateNotification(context: Context) { + updateForegroundNotification() } - // Send broadcast to notify components like QSTileService - val intent = Intent(BROADCAST_ACTION_FOREGROUND) - intent.putExtra(BROADCAST_FOREGROUND_INTENT_EXTRA, isRunning) - sendBroadcast(intent) - Log.d("ForegroundService", "Service running state set to $isRunning and broadcast sent.") } + // --- 通知创建 --- - @SuppressLint("LaunchActivityFromNotification") // If PendingIntent launches Activity - fun createForegroundNotification(context: Context): Notification { + private fun createForegroundNotification(context: Context): Notification { val currentVolume = getVolumePercentage() - val currentVolumeLevel = getVolumeLevel(currentVolume) - // Ensure volumeComment is initialized - val comment = - if (::volumeComment.isInitialized && currentVolumeLevel < volumeComment.size) { - volumeComment[currentVolumeLevel] - } else { - "Volume" // Fallback - } + val volumeLevel = getVolumeLevel(currentVolume) + val comment = volumeComment.getOrElse(volumeLevel) { "Volume" } - val nIcon = generateNotificationIcon(context) + val contentText = String.format( + resources.getString(R.string.current_volume_percent), + comment, + currentVolume + ) - // --- Intents for Actions --- - val settingsIntent = Intent(this, MainActivity::class.java).apply { - action = ACTION_NAME_SETTINGS - } - val settingsPendingIntent: PendingIntent = - PendingIntent.getBroadcast( - this, - 0, - settingsIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) // Use UPDATE_CURRENT if intent extras change - - val sleepIntent = Intent(context, MuteMediaReceiver::class.java).apply { - action = BROADCAST_ACTION_SLEEPTIMER_TOGGLE - } - val pendingSleepIntent = PendingIntent.getBroadcast( - context, - 2, - sleepIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) // Different request code (2) + return NotificationCompat.Builder(this, Constants.CHANNEL_ID_DEFAULT) + .setContentTitle(getString(R.string.to_be_or_not)) + .setContentText(contentText) + .setSmallIcon(generateNotificationIcon(context, currentVolume, volumeLevel)) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setContentIntent(createMutePendingIntent(context)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setGroup(Constants.ID_NOTIFICATION_GROUP_FORE) + .addAction(createSettingsAction(context)) + .addAction(createSleepTimerAction(context)) + .build() + } - val muteMediaIntent = Intent(context, MuteMediaReceiver::class.java).apply { - action = BROADCAST_ACTION_MUTE + private fun createSettingsAction(context: Context): NotificationCompat.Action { + val intent = Intent(this, MainActivity::class.java).apply { + action = Constants.ACTION_NAME_SETTINGS } - val pendingMuteIntent = PendingIntent.getBroadcast( + val pendingIntent = PendingIntent.getBroadcast( context, - 3, - muteMediaIntent, + REQUEST_CODE_SETTINGS, + intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) // Different request code (3) - - val sleepNotification = find() // From SleepNotification object - val sleepTitle = if (sleepNotification != null) { - DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(sleepNotification.`when`)) - } else { - resources.getString(R.string.sleep) - } - - // --- Build Actions --- - val actionSettings: NotificationCompat.Action = NotificationCompat.Action.Builder( + ) + return NotificationCompat.Action.Builder( R.drawable.ic_baseline_settings_24, resources.getString(R.string.settings), - settingsPendingIntent - ).build() - - val actionSleepTimer: NotificationCompat.Action = NotificationCompat.Action.Builder( - R.drawable.ic_tile, // Consider a sleep-specific icon - sleepTitle, - pendingSleepIntent + pendingIntent ).build() + } + private fun createSleepTimerAction(context: Context): NotificationCompat.Action { + val sleepNotification = find() + val sleepTitle = sleepNotification?.let { + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(it.`when`)) + } ?: resources.getString(R.string.sleep) - // Build Notification - return NotificationCompat.Builder(this, CHANNEL_ID_DEFAULT) - .setContentTitle(getString(R.string.to_be_or_not)) // Consider more descriptive title - .setOnlyAlertOnce(true) - .setContentText( - String.format( - resources.getString(R.string.current_volume_percent), - comment, - currentVolume - ) - ) - .setSmallIcon(nIcon) - .setOngoing(true) - .setContentIntent(pendingMuteIntent) - .setPriority(NotificationCompat.PRIORITY_LOW) - .addAction(actionSettings) - .addAction(actionSleepTimer) - .setGroup(ID_NOTIFICATION_GROUP_FORE) - .setGroupSummary(false) - .build() + val intent = Intent(context, MuteMediaReceiver::class.java).apply { + action = Constants.BROADCAST_ACTION_SLEEPTIMER_TOGGLE + } + val pendingIntent = PendingIntent.getBroadcast( + context, + REQUEST_CODE_SLEEP_TIMER, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + return NotificationCompat.Action.Builder(R.drawable.ic_tile, sleepTitle, pendingIntent).build() } - fun createProtectionNotification(): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID_PROTECT) - .setContentTitle(getString(R.string.ears_protected)) - .setSmallIcon(R.drawable.ic_headphones_protection) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setGroup(ID_NOTIFICATION_GROUP_PROTECT) - .setTimeoutAfter(3000) - .setGroupSummary(false) - .build() + private fun createMutePendingIntent(context: Context): PendingIntent { + val intent = Intent(context, MuteMediaReceiver::class.java).apply { + action = Constants.BROADCAST_ACTION_MUTE + } + return PendingIntent.getBroadcast( + context, + REQUEST_CODE_MUTE, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) } @SuppressLint("DiscouragedApi") - private fun generateNotificationIcon(context: Context): IconCompat { - val currentVolume = getVolumePercentage() - - val resourceName = "num_${currentVolume}" + private fun generateNotificationIcon(context: Context, volumePercent: Int, volumeLevel: Int): IconCompat { + val resourceName = "num_${volumePercent}" val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName) - return if (resourceId != 0) IconCompat.createWithResource(this, resourceId) - else IconCompat.createWithResource( - context, - volumeDrawableIds[getVolumeLevel(currentVolume)] - ) // Fallback to image mode + return if (resourceId != 0) { + IconCompat.createWithResource(this, resourceId) + } else { + val fallbackIconRes = when(volumeLevel) { + 0 -> R.drawable.ic_volume_silent + 1 -> R.drawable.ic_volume_low + 2 -> R.drawable.ic_volume_middle + 3 -> R.drawable.ic_volume_high + else -> R.drawable.ic_volume_mega + } + IconCompat.createWithResource(context, fallbackIconRes) + } } -} \ No newline at end of file +} From c601cc28b5f01350296647f068ed1747241c6366 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sat, 14 Jun 2025 21:16:40 +0800 Subject: [PATCH 16/48] Update Compose BOM and remove redundant version variables This commit updates the Compose BOM to version `2025.06.00`. It also removes the `lifecycle_version` and `room_version` variables, as their values were already hardcoded in the dependencies. --- app/build.gradle | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 17bcbe6..0d1d5b6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,7 +101,7 @@ dependencies { implementation 'androidx.databinding:databinding-runtime:8.10.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.1' implementation 'androidx.activity:activity-compose:1.10.1' - implementation platform('androidx.compose:compose-bom:2025.04.01') + implementation platform('androidx.compose:compose-bom:2025.06.00') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' @@ -111,24 +111,21 @@ dependencies { debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' - def lifecycle_version = '2.9.1' - // LiveData - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.1" + implementation "androidx.lifecycle:lifecycle-common-java8:2.9.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.1" + implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1" implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1' implementation "com.google.dagger:hilt-android:2.56.2" ksp "com.google.dagger:hilt-compiler:2.56.1" - def room_version = '2.7.1' - implementation "androidx.room:room-runtime:$room_version" - annotationProcessor "androidx.room:room-compiler:$room_version" + implementation "androidx.room:room-runtime:2.7.1" + annotationProcessor "androidx.room:room-compiler:2.7.1" implementation 'androidx.room:room-ktx:2.7.1' - ksp "androidx.room:room-compiler:$room_version" + ksp "androidx.room:room-compiler:2.7.1" implementation "androidx.datastore:datastore-preferences:1.2.0-alpha02" From 6795ee166f3f285e5941d60e472bfe8be16244d7 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 14:37:59 +0800 Subject: [PATCH 17/48] Refactor: Organize Activities into `activity` package This commit moves `MainActivity` and `HistoryActivity` into a new `activity` subpackage to improve project structure. Key changes: - Moved `MainActivity.kt` to `activity/MainActivity.kt` and updated its package declaration. - Moved `HistoryActivity.kt` to `activity/HistoryActivity.kt` and updated its package declaration. - Updated `AndroidManifest.xml` to reflect the new activity paths for `MainActivity`, `MainActivityAlias`, and `HistoryActivity`. - Changed `android:label` for `MainActivityAlias` to `@string/app_name`. - Set `android:enabled="false"` for `MainActivityAlias` to ensure it's not the default launcher activity initially. - Updated `HistoryTileService.kt` to import `HistoryActivity` from the new `activity` package. - Removed unused imports from `MainActivity.kt`. --- app/src/main/AndroidManifest.xml | 11 +++++------ .../liveinpeace/{ => activity}/HistoryActivity.kt | 5 ++++- .../maary/liveinpeace/{ => activity}/MainActivity.kt | 9 +-------- .../maary/liveinpeace/service/HistoryTileService.kt | 2 +- 4 files changed, 11 insertions(+), 16 deletions(-) rename app/src/main/java/com/maary/liveinpeace/{ => activity}/HistoryActivity.kt (98%) rename app/src/main/java/com/maary/liveinpeace/{ => activity}/MainActivity.kt (63%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2d1b49..e7b2601 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,17 +21,16 @@ android:theme="@style/Theme.LiveInPeace" tools:ignore="UnusedAttribute"> + android:enabled="false" + android:targetActivity=".activity.MainActivity"> @@ -39,7 +38,7 @@ Date: Sun, 15 Jun 2025 14:38:15 +0800 Subject: [PATCH 18/48] Refactor: Use `PendingIntent.getActivity` for settings action This commit changes the `PendingIntent` for the settings action in the foreground service notification. Instead of using `PendingIntent.getBroadcast` with a custom action, it now uses `PendingIntent.getActivity` to directly launch `MainActivity`. This aligns with the intended behavior of opening the app's settings. Additionally, `launchIn(viewModelScope)` has been added to the preference flows in `SettingsViewModel` to ensure they are properly collected within the ViewModel's lifecycle. --- .../com/maary/liveinpeace/service/ForegroundService.kt | 9 +++------ .../com/maary/liveinpeace/viewmodel/SettingsViewModel.kt | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) 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 2ed6cd5..1772057 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -22,9 +22,8 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import com.maary.liveinpeace.Constants import com.maary.liveinpeace.DeviceTimer -import com.maary.liveinpeace.MainActivity +import com.maary.liveinpeace.activity.MainActivity import com.maary.liveinpeace.R -import com.maary.liveinpeace.SleepNotification import com.maary.liveinpeace.SleepNotification.find import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.database.ConnectionDao @@ -505,10 +504,8 @@ class ForegroundService : Service() { } private fun createSettingsAction(context: Context): NotificationCompat.Action { - val intent = Intent(this, MainActivity::class.java).apply { - action = Constants.ACTION_NAME_SETTINGS - } - val pendingIntent = PendingIntent.getBroadcast( + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( context, REQUEST_CODE_SETTINGS, intent, 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 f5356b5..f35e12f 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.content.pm.PackageManager import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.maary.liveinpeace.MainActivity import com.maary.liveinpeace.database.PreferenceRepository import com.maary.liveinpeace.service.ForegroundService import dagger.hilt.android.lifecycle.HiltViewModel @@ -96,10 +95,10 @@ class SettingsViewModel @Inject constructor( }.launchIn(viewModelScope) preferenceRepository.isEarProtectionOn().onEach { _protectionSwitchState.value = it - } + }.launchIn(viewModelScope) preferenceRepository.isHideInLauncher().onEach { _hideInLauncherSwitchState.value = it - } + }.launchIn(viewModelScope) } } \ No newline at end of file From d75dac93c317102d7b60c97c1d98d47a7eeebad2 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 14:51:19 +0800 Subject: [PATCH 19/48] Refactor: Implement navigation in SettingsScreen This commit implements navigation functionality in the `SettingsScreen`. - The close button in the top app bar now finishes the `SettingsActivity`. - The history button in the top app bar now navigates to `HistoryActivity`. --- .../liveinpeace/ui/screen/SettingsScreen.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 dcfcb58..c81c17e 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 @@ -1,6 +1,7 @@ package com.maary.liveinpeace.ui.screen import android.Manifest +import android.app.Activity import android.content.Intent import android.os.Build import android.provider.Settings @@ -56,6 +57,7 @@ import com.maary.liveinpeace.viewmodel.SettingsViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberPermissionState +import com.maary.liveinpeace.activity.HistoryActivity @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @@ -89,13 +91,23 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Text(stringResource(R.string.app_name)) }, navigationIcon = { - IconButton(onClick = { /* TODO: exit activity */ }) { + IconButton(onClick = { + //exit the settings screen + // this could be a popBackStack or finish depending on your navigation setup + // For example, if using Jetpack Navigation: + (context as? Activity)?.finish() + // If using a navigation component, you might want to use: + // navController.popBackStack() + }) { Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.exit)) } }, actions = { - IconButton(onClick = {/* TODO: open history activity*/} ) { + IconButton(onClick = { + val intent = Intent(context, HistoryActivity::class.java) + context.startActivity(intent) + } ) { Icon(painter = painterResource(R.drawable.ic_action_history), contentDescription = stringResource(R.string.connections_history)) } From d1f145b971043c2773dcd591cbbff46e51aaf3bc Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 19:35:04 +0800 Subject: [PATCH 20/48] Implement WelcomeActivity and WelcomeViewModel This commit introduces a new welcome flow for the application. - **WelcomeActivity**: A new `ComponentActivity` that displays the `WelcomeScreen`. It handles edge-to-edge display. - **WelcomeViewModel**: - Manages the state for notification permission, battery optimization status, and app icon visibility. - Provides functions to: - Handle permission results. - Check and update battery optimization status. - Toggle the app icon's visibility in the launcher by enabling/disabling `MainActivityAlias`. - Persist the "welcome finished" state and app icon visibility preference using `PreferenceRepository`. - Start the `ForegroundService` when the welcome flow is completed. - Observes the `isShowingIcon` flow from `PreferenceRepository` to initialize the app icon state. - **WelcomeScreen (Composable)**: - Displays UI for requesting necessary and optional permissions/settings: - Notification permission. - Disabling battery optimization. - Showing/hiding the app icon in the launcher. - Uses `rememberLauncherForActivityResult` to request notification permission. - Updates battery optimization status on resume using `LifecycleEventObserver`. - Provides a "Finish" button that becomes enabled once notification permission is granted, which then calls `welcomeViewModel.welcomeFinished()`. - **PreferenceRepository**: - Added `isShowingIcon()` flow to observe the app icon visibility state. - Added `setShowIcon()` function to persist the app icon visibility state. - **AndroidManifest.xml**: - Registered `WelcomeActivity`. - Set `MainActivityAlias` to `android:enabled="false"` by default. - **SettingsComponents.kt**: - Added new composable functions (`TopBox`, `MiddleBox`, `BottomBox`, `StandaloneBox`) for styling settings items with rounded corners. - Modified `SwitchRow` to trigger its `onCheckedChange` callback when the entire row is tapped. - **strings.xml**: Added new string resources for the welcome screen. --- app/src/main/AndroidManifest.xml | 16 +- .../liveinpeace/activity/WelcomeActivity.kt | 33 +++ .../database/PreferenceRepository.kt | 13 ++ .../ui/screen/SettingsComponents.kt | 61 ++++++ .../liveinpeace/ui/screen/WelcomeScreen.kt | 189 ++++++++++++++++++ .../liveinpeace/viewmodel/WelcomeViewModel.kt | 77 +++++++ app/src/main/res/values/strings.xml | 10 + 7 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt create mode 100644 app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7b2601..718e95e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,23 +20,29 @@ android:supportsRtl="true" android:theme="@style/Theme.LiveInPeace" tools:ignore="UnusedAttribute"> + - + android:theme="@style/Theme.LiveInPeace"/> + + android:targetActivity=".activity.MainActivity" + android:theme="@style/Theme.LiveInPeace"> + diff --git a/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt b/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt new file mode 100644 index 0000000..aab7924 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt @@ -0,0 +1,33 @@ +package com.maary.liveinpeace.activity + +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.maary.liveinpeace.ui.screen.WelcomeScreen +import com.maary.liveinpeace.ui.theme.LiveInPeaceTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class WelcomeActivity : ComponentActivity() { + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LiveInPeaceTheme { + WelcomeScreen() + } + } + } +} + 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 02d6d70..e302308 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -100,4 +100,17 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont pref[PREF_HIDE_IN_LAUNCHER] = state } } + + fun isShowingIcon(): Flow { + return datastore.data.map { pref -> + val isHidden = pref[PREF_HIDE_IN_LAUNCHER] ?: true + !isHidden + } + } + + suspend fun setShowIcon(state: Boolean) { + datastore.edit { pref -> + pref[PREF_HIDE_IN_LAUNCHER] = !state + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 8cd749b..efde5ba 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -1,5 +1,6 @@ package com.maary.liveinpeace.ui.screen +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -9,10 +10,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch @@ -24,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -105,6 +109,7 @@ fun SwitchRow( .pointerInput(Unit) { detectTapGestures { onCheckedChange(!state) // 当点击 SwitchRow 时触发点击事件 + //todo change to clickable } } .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), @@ -158,4 +163,60 @@ fun DropdownRow(options: MutableList, position: Int, onItemClicked: (Int DropdownItem(modifier = Modifier.weight(2f), options = options, position = position, onItemClicked = onItemClicked) } +} + +@Composable +fun TopBox(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +fun MiddleBox(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 4.dp, bottomEnd = 4.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +fun BottomBox(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + contentAlignment = Alignment.Center + ) { + content() + } +} + +@Composable +fun StandaloneBox(content: @Composable () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow), + contentAlignment = Alignment.Center + ) { + content() + } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt new file mode 100644 index 0000000..a25856e --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -0,0 +1,189 @@ +package com.maary.liveinpeace.ui.screen + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.maary.liveinpeace.R +import com.maary.liveinpeace.viewmodel.WelcomeViewModel +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +@SuppressLint("BatteryLife") +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + val context = LocalContext.current + + val hasNotificationPermission by welcomeViewModel.hasNotificationPermission.collectAsState() + val isIgnoringBatteryOptimizations by welcomeViewModel.isIgnoringBatteryOptimizations.collectAsState() + val showIconState by welcomeViewModel.showIconState.collectAsState() + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + welcomeViewModel.onPermissionResult(isGranted) + } + ) + + LaunchedEffect(Unit) { + val alreadyGranted = + context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + welcomeViewModel.onPermissionResult(alreadyGranted) + welcomeViewModel.checkBatteryOptimizationStatus() + } + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if ( event == Lifecycle.Event.ON_RESUME) { + welcomeViewModel.checkBatteryOptimizationStatus() + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Scaffold ( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.primary), + title = { + Text(stringResource(R.string.welcome)) + }, + navigationIcon = { + IconButton(onClick = { (context as? Activity)?.finish() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.exit) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(top = innerPadding.calculateTopPadding()) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .fillMaxSize() + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.nessery_permissions), + style = TextStyle( + fontWeight = FontWeight.Bold, + ) + ) + StandaloneBox { + SwitchRow( + title = stringResource(R.string.notification_permission), + description = stringResource(R.string.notification_permission_description), + state = hasNotificationPermission,/*todo*/ + ) { + permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.optional_permissions), + style = TextStyle( + fontWeight = FontWeight.Bold + ) + ) + TopBox { + SwitchRow( + title = stringResource(R.string.disable_battery_optimization), + description = stringResource(R.string.disable_battery_optimization_description), + state = isIgnoringBatteryOptimizations, + ) { + val batteryIntent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + batteryIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .data = "package:${context.packageName}".toUri() + context.startActivity(batteryIntent) + } + } + BottomBox { + SwitchRow( + title = stringResource(R.string.show_icon), + description = stringResource(R.string.show_icon_description), + state = showIconState, + ) { + welcomeViewModel.toggleShowIcon() + } + } + } + Row(modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End) { + Button( + modifier = Modifier.padding(8.dp), + enabled = hasNotificationPermission, + onClick = { + welcomeViewModel.welcomeFinished() + (context as? Activity)?.finish() + }) { + Text(stringResource(R.string.finish)) + } + } + Spacer(Modifier.height(innerPadding.calculateBottomPadding())) + } + } +} + diff --git a/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt new file mode 100644 index 0000000..9775fa1 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt @@ -0,0 +1,77 @@ +package com.maary.liveinpeace.viewmodel + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.PowerManager +import androidx.core.content.ContextCompat.startForegroundService +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.maary.liveinpeace.database.PreferenceRepository +import com.maary.liveinpeace.service.ForegroundService +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WelcomeViewModel @Inject constructor( + @ApplicationContext private val application: Context, + private val preferenceRepository: PreferenceRepository +): ViewModel() { + + private val _hasNotificationPermission = MutableStateFlow(false) + val hasNotificationPermission = _hasNotificationPermission.asStateFlow() + + fun onPermissionResult(isGranted: Boolean) { + _hasNotificationPermission.value = isGranted + } + + private val _isIgnoringBatteryOptimizations = MutableStateFlow(false) + val isIgnoringBatteryOptimizations = _isIgnoringBatteryOptimizations.asStateFlow() + + fun checkBatteryOptimizationStatus() { + val powerManager = application.getSystemService(Context.POWER_SERVICE) as PowerManager + val packageName = application.packageName + + _isIgnoringBatteryOptimizations.value = powerManager.isIgnoringBatteryOptimizations(packageName) + } + + private val _showIconState = MutableStateFlow(false) + val showIconState = _showIconState.asStateFlow() + + fun toggleShowIcon() { + viewModelScope.launch { + val packageManager = application.packageManager + val componentName = ComponentName(application, "${application.packageName}.MainActivityAlias") + + val newState = if(!_showIconState.value) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) + _showIconState.value = !_showIconState.value + preferenceRepository.setShowIcon(_showIconState.value) + } + } + + fun welcomeFinished() { + viewModelScope.launch { + preferenceRepository.setWelcomeFinished(true) + } + val intent = Intent(application, ForegroundService::class.java) + startForegroundService(application, intent) + } + + init { + preferenceRepository.isShowingIcon().onEach { + _showIconState.value = it + }.launchIn(viewModelScope) + } +} \ 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 a81df90..1a3e738 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,4 +58,14 @@ Configure notification importance Exit Hide In Launcher + Finish + Nessery + Optional + Notification Permission + This app needs notification permission to show the current media volume in the notification bar. + Disable Battery Optimization + Turning off battery optimization for this app helps prevent the system from stopping it unexpectedly, reducing potential errors. + Show App Icon in Launcher + Tapping the icon will take you to the settings page. + WelcomeActivity \ No newline at end of file From a4bbb522079f88329389b96e3534e3b0097221b2 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 19:35:18 +0800 Subject: [PATCH 21/48] Refactor QSTileService and SettingsViewModel This commit refactors the `QSTileService` and `SettingsViewModel`. **QSTileService:** - Simplified the `onClick` logic. - If the welcome process is not finished, it now directly launches `WelcomeActivity`. - Removed the logic for requesting notification permissions and creating a welcome notification within the `QSTileService` as this is now handled by `WelcomeActivity`. - Ensured that `ForegroundService` is only started or stopped after the welcome check. **SettingsViewModel:** - Fixed an issue where the `_hideInLauncherSwitchState` was not being updated correctly after changing the component enabled setting. It now correctly reflects the new state. --- .../liveinpeace/service/QSTileService.kt | 106 +++--------------- .../viewmodel/SettingsViewModel.kt | 1 + 2 files changed, 18 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index dbae921..d7440a4 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -27,6 +27,7 @@ import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_SETTINGS import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_WELCOME import com.maary.liveinpeace.Constants.Companion.REQUESTING_WAIT_MILLIS import com.maary.liveinpeace.R +import com.maary.liveinpeace.activity.WelcomeActivity import com.maary.liveinpeace.database.PreferenceRepository import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -64,60 +65,43 @@ class QSTileService: TileService() { serviceScope.cancel() } + @SuppressLint("StartActivityAndCollapseDeprecated") @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onClick() { super.onClick() val tile = qsTile - var waitMillis = REQUESTING_WAIT_MILLIS - val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager - - while(ActivityCompat.checkSelfPermission( - applicationContext, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - Log.v("MUTE_", waitMillis.toString()) - requestNotificationsPermission() - Thread.sleep(waitMillis.toLong()) - waitMillis *= 2 + var intent = Intent(this, WelcomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - - val intent = Intent(this, ForegroundService::class.java) - - serviceScope.launch { - preferenceRepository.isWelcomeFinished().collect { - if (!it) { - //todo: redirect to welcome activity/screen - if ( powerManager.isIgnoringBatteryOptimizations(packageName) && - ActivityCompat.checkSelfPermission( - applicationContext, Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED) { - preferenceRepository.setWelcomeFinished(true) - } else { - createWelcomeNotification() - Thread.sleep(waitMillis.toLong()) - waitMillis *= 2 - } + if (!preferenceRepository.isWelcomeFinished().first()) { + val pendingIntent = PendingIntent.getActivity( + this@QSTileService, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse(pendingIntent) + } else { + startActivityAndCollapse(intent) } + return@launch } - + intent = Intent(this@QSTileService, ForegroundService::class.java) if (preferenceRepository.isServiceRunning().first()) { stopService(intent) preferenceRepository.setServiceRunning(false) updateTileState(false) tile.updateTile() - } else { startForegroundService(intent) preferenceRepository.setServiceRunning(true) updateTileState(true) tile.updateTile() - } } - tile.updateTile() } @@ -164,60 +148,4 @@ class QSTileService: TileService() { } tile.updateTile() } - - private fun createNotificationChannel(importance:Int, id: String ,name:String, descriptionText: String) { - //val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(id, name, importance).apply { - description = descriptionText - } - // Register the channel with the system - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - - private fun requestNotificationsPermission() { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - } - val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivityAndCollapse(pendingIntent) - } - } - - @SuppressLint("BatteryLife") - private fun createWelcomeNotification() { - Log.v("MUTE_", "CREATING WELCOME") - val welcome = NotificationCompat.Builder(this, CHANNEL_ID_WELCOME) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_baseline_settings_24) - .setShowWhen(false) - .setContentTitle(getString(R.string.welcome)) - .setContentText(getString(R.string.welcome_description)) - .setOnlyAlertOnce(true) - .setGroupSummary(false) - .setGroup(ID_NOTIFICATION_GROUP_SETTINGS) - - val batteryIntent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) - batteryIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .data = Uri.parse("package:$packageName") - - val pendingBatteryIntent = PendingIntent.getActivity(this, 0, batteryIntent, PendingIntent.FLAG_IMMUTABLE) - - val batteryAction = NotificationCompat.Action.Builder( - R.drawable.outline_battery_saver_24, - getString(R.string.request_permission_battery), - pendingBatteryIntent - ).build() - - welcome.addAction(batteryAction) - - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.notify(ID_NOTIFICATION_WELCOME, welcome.build()) - - } } \ No newline at end of file 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 f35e12f..3644d42 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -82,6 +82,7 @@ class SettingsViewModel @Inject constructor( } packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) + _hideInLauncherSwitchState.value = !_hideInLauncherSwitchState.value preferenceRepository.setHideInLauncher(!_hideInLauncherSwitchState.value) } } From 72008ed503f1726071ff81958f70db393a457110 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 19:41:33 +0800 Subject: [PATCH 22/48] Refactor "Hide in Launcher" to "Show Icon" and improve SwitchRow This commit refactors the "Hide in Launcher" setting to "Show Icon" for better clarity. - **SettingsScreen:** - Renamed the switch title from "Hide in Launcher" to "Show Icon". - Updated the description to "Show app icon in the launcher". - The switch state now reflects `settingsViewModel.showIconState`. - The `onCheckedChange` lambda now calls `settingsViewModel.toggleShowIcon()`. - **SettingsViewModel:** - Renamed `_hideInLauncherSwitchState` to `_showIconState`. - Renamed `hideInLauncherSwitchState` to `showIconState`. - Renamed `hideInLauncherSwitch()` to `toggleShowIcon()`. - The logic for `toggleShowIcon()` is inverted to match the "Show Icon" behavior. - `preferenceRepository.setShowIcon()` is now called with the new `_showIconState.value`. - Preference loading now updates `_showIconState` with `isHideInLauncher()` (which implicitly becomes `isShowIcon`). - **SettingsComponents.kt:** - Changed `SwitchRow` interaction from `pointerInput` with `detectTapGestures` to a standard `clickable` modifier for simplicity and better accessibility. - **WelcomeScreen.kt:** - Removed a `/*todo*/` comment. - **HistoryTileService.kt:** - Added `@SuppressLint("StartActivityAndCollapseDeprecated")` to `onClick` method to suppress lint warning for `startActivityAndCollapse` which is deprecated but necessary for TileService functionality. --- .../liveinpeace/service/HistoryTileService.kt | 2 ++ .../ui/screen/SettingsComponents.kt | 7 +------ .../liveinpeace/ui/screen/SettingsScreen.kt | 11 ++++++----- .../liveinpeace/ui/screen/WelcomeScreen.kt | 2 +- .../viewmodel/SettingsViewModel.kt | 19 +++++++++---------- 5 files changed, 19 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/service/HistoryTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/HistoryTileService.kt index e76c1e4..5b86907 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/HistoryTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/HistoryTileService.kt @@ -1,5 +1,6 @@ package com.maary.liveinpeace.service +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK @@ -8,6 +9,7 @@ import android.service.quicksettings.TileService import com.maary.liveinpeace.activity.HistoryActivity class HistoryTileService: TileService() { + @SuppressLint("StartActivityAndCollapseDeprecated") override fun onClick() { super.onClick() diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index efde5ba..a70afaf 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -106,12 +106,7 @@ fun SwitchRow( Row( modifier = Modifier .fillMaxWidth() - .pointerInput(Unit) { - detectTapGestures { - onCheckedChange(!state) // 当点击 SwitchRow 时触发点击事件 - //todo change to clickable - } - } + .clickable { onCheckedChange(!state) } .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically 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 c81c17e..e4ac6b8 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 @@ -157,11 +157,12 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { ) SwitchRow( - title = stringResource(R.string.hide_in_launcher), - description = stringResource(R.string.hide_in_launcher) /* todo */, - state = settingsViewModel.hideInLauncherSwitchState.collectAsState().value, - onCheckedChange = { settingsViewModel.hideInLauncherSwitch() } - ) + title = stringResource(R.string.show_icon), + description = stringResource(R.string.show_icon_description), + state = settingsViewModel.showIconState.collectAsState().value, + ) { + settingsViewModel.toggleShowIcon() + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt index a25856e..b3e79fa 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -135,7 +135,7 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { SwitchRow( title = stringResource(R.string.notification_permission), description = stringResource(R.string.notification_permission_description), - state = hasNotificationPermission,/*todo*/ + state = hasNotificationPermission, ) { permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } 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 3644d42..ff11269 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -67,23 +67,22 @@ class SettingsViewModel @Inject constructor( } } - private val _hideInLauncherSwitchState = MutableStateFlow(false) - val hideInLauncherSwitchState : StateFlow = _hideInLauncherSwitchState.asStateFlow() + private val _showIconState = MutableStateFlow(false) + val showIconState = _showIconState.asStateFlow() - fun hideInLauncherSwitch() { + fun toggleShowIcon() { viewModelScope.launch { val packageManager = application.packageManager val componentName = ComponentName(application, "${application.packageName}.MainActivityAlias") - val newState = if(!_hideInLauncherSwitchState.value) { - PackageManager.COMPONENT_ENABLED_STATE_DISABLED - } else { + val newState = if(!_showIconState.value) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED } - packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) - _hideInLauncherSwitchState.value = !_hideInLauncherSwitchState.value - preferenceRepository.setHideInLauncher(!_hideInLauncherSwitchState.value) + _showIconState.value = !_showIconState.value + preferenceRepository.setShowIcon(_showIconState.value) } } @@ -98,7 +97,7 @@ class SettingsViewModel @Inject constructor( _protectionSwitchState.value = it }.launchIn(viewModelScope) preferenceRepository.isHideInLauncher().onEach { - _hideInLauncherSwitchState.value = it + _showIconState.value = it }.launchIn(viewModelScope) } From 2b6033a10fe17a791c8b84687152b0b2c9392944 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Sun, 15 Jun 2025 20:36:41 +0800 Subject: [PATCH 23/48] Refactor service start on boot with WorkManager This commit refactors the `BootCompleteReceiver` to use `WorkManager` for starting the `ForegroundService` on device boot. - A new `BootWorker` (`CoroutineWorker`) is introduced to handle the foreground service start logic. - The `BootCompleteReceiver` now enqueues a `OneTimeWorkRequest` for the `BootWorker` when `Intent.ACTION_BOOT_COMPLETED` is received. This approach aligns with modern Android practices for background tasks and service initiation. - Added `androidx.work:work-runtime-ktx` dependency. --- app/build.gradle | 2 ++ .../java/com/maary/liveinpeace/BootWorker.kt | 29 +++++++++++++++++++ .../receiver/BootCompleteReceiver.kt | 20 +++++++++---- 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/maary/liveinpeace/BootWorker.kt diff --git a/app/build.gradle b/app/build.gradle index 0d1d5b6..67eb439 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -132,6 +132,8 @@ dependencies { 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.1" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/app/src/main/java/com/maary/liveinpeace/BootWorker.kt b/app/src/main/java/com/maary/liveinpeace/BootWorker.kt new file mode 100644 index 0000000..5703834 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/BootWorker.kt @@ -0,0 +1,29 @@ +package com.maary.liveinpeace + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.maary.liveinpeace.service.ForegroundService + +class BootWorker ( + private val context: Context, + workerParams: WorkerParameters) + : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + // 在這裡執行啟動前台服務的邏輯 + // WorkManager 在執行 doWork 時,系統允許應用啟動前台服務 + val intent = Intent(context, ForegroundService::class.java) + + try { + context.startForegroundService(intent) + // 任務成功 + return Result.success() + } catch (e: Exception) { + // 如果啟動失敗,記錄錯誤並回報失敗 + Log.e("BootWorker", "Failed to start foreground service", e) + return Result.failure() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt b/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt index 734f3c2..82c6c73 100644 --- a/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt +++ b/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt @@ -4,12 +4,22 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.maary.liveinpeace.BootWorker import com.maary.liveinpeace.service.ForegroundService -class BootCompleteReceiver: BroadcastReceiver() { - override fun onReceive(p0: Context?, p1: Intent?) { - Log.d("=boot complete=", "Intent.ACTION_BOOT_COMPLETED") - val intent = Intent(p0, ForegroundService::class.java) - p0?.startForegroundService(intent) +class BootCompleteReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + // 確保 context 不為空,且收到的廣播是正確的 + if (context != null && intent?.action == Intent.ACTION_BOOT_COMPLETED) { + Log.d("=boot complete=", "Received boot completed intent, enqueuing worker.") + + // 1. 創建一個一次性的工作請求 + val bootWorkRequest = OneTimeWorkRequestBuilder().build() + + // 2. 將這個請求加入 WorkManager 的佇列中 + WorkManager.getInstance(context).enqueue(bootWorkRequest) + } } } \ No newline at end of file From 8fea9454ce356955c7caae6c2aa82434fe7facaa Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:58:39 +0800 Subject: [PATCH 24/48] Refactor SettingsViewModel to use StateFlow from repository This commit refactors `SettingsViewModel` to directly expose `StateFlow`s from `PreferenceRepository` for managing UI states related to: - Foreground service status - Alert/watching state - Ear protection status - Launcher icon visibility Key changes: - Removed private `MutableStateFlow` instances and their corresponding public `StateFlow` counterparts (`_foregroundSwitchState`, `_alertSwitchState`, `_protectionSwitchState`, `_showIconState`). - Directly assigned public `StateFlow` properties (`foregroundSwitchState`, `alertSwitchState`, `protectionSwitchState`, `showIconState`) by collecting flows from `preferenceRepository` using `stateIn` with `SharingStarted.WhileSubscribed(5000)`. - Updated `foregroundSwitch()`, `alertSwitch()`, `protectionSwitch()`, and `toggleShowIcon()` methods to use the new public `StateFlow` values for their logic. - Removed the `init` block that was previously used to initialize the `MutableStateFlow`s by observing repository flows. - In `toggleShowIcon()`, the logic for updating the package manager and then persisting the new state to the repository is maintained, now using the value from the public `showIconState` `StateFlow`. --- .../viewmodel/SettingsViewModel.kt | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) 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 ff11269..f80f852 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -4,18 +4,24 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_LOWER_THRESHOLD +import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_UPPER_THRESHOLD import com.maary.liveinpeace.database.PreferenceRepository import com.maary.liveinpeace.service.ForegroundService import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @HiltViewModel @@ -24,11 +30,11 @@ class SettingsViewModel @Inject constructor( private val preferenceRepository: PreferenceRepository ): ViewModel() { - private val _foregroundSwitchState = MutableStateFlow(false) - val foregroundSwitchState : StateFlow = _foregroundSwitchState.asStateFlow() + val foregroundSwitchState: StateFlow = preferenceRepository.isServiceRunning() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun foregroundSwitch() { - if(!_foregroundSwitchState.value) { + if(!foregroundSwitchState.value) { startForegroundService() } else { stopForegroundService() @@ -49,56 +55,44 @@ class SettingsViewModel @Inject constructor( } } - private val _alertSwitchState = MutableStateFlow(false) - val alertSwitchState : StateFlow = _alertSwitchState.asStateFlow() + val alertSwitchState: StateFlow = preferenceRepository.getWatchingState() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun alertSwitch() { viewModelScope.launch { - preferenceRepository.setWatchingState(!_alertSwitchState.value) + preferenceRepository.setWatchingState(!alertSwitchState.value) } } - private val _protectionSwitchState = MutableStateFlow(false) - val protectionSwitchState : StateFlow = _protectionSwitchState.asStateFlow() + val protectionSwitchState: StateFlow = preferenceRepository.isEarProtectionOn() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun protectionSwitch() { viewModelScope.launch { - preferenceRepository.setEarProtection(!_protectionSwitchState.value) + preferenceRepository.setEarProtection(!protectionSwitchState.value) } } - private val _showIconState = MutableStateFlow(false) - val showIconState = _showIconState.asStateFlow() + val showIconState: StateFlow = preferenceRepository.isHideInLauncher() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun toggleShowIcon() { viewModelScope.launch { + val newState = !showIconState.value + + // 1. 执行系统操作 val packageManager = application.packageManager val componentName = ComponentName(application, "${application.packageName}.MainActivityAlias") - - val newState = if(!_showIconState.value) { + val enabledState = if (newState) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED } else { PackageManager.COMPONENT_ENABLED_STATE_DISABLED } - packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) - _showIconState.value = !_showIconState.value - preferenceRepository.setShowIcon(_showIconState.value) - } - } + packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) - init { - preferenceRepository.isServiceRunning().onEach { - _foregroundSwitchState.value = it - }.launchIn(viewModelScope) - preferenceRepository.getWatchingState().onEach { - _alertSwitchState.value = it - }.launchIn(viewModelScope) - preferenceRepository.isEarProtectionOn().onEach { - _protectionSwitchState.value = it - }.launchIn(viewModelScope) - preferenceRepository.isHideInLauncher().onEach { - _showIconState.value = it - }.launchIn(viewModelScope) + // 2. 将新状态通知 Repository + preferenceRepository.setShowIcon(newState) + } } } \ No newline at end of file From bbe8e32c49b6763aa3286122c069f2b6e7653d41 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 21:54:33 +0800 Subject: [PATCH 25/48] Refactor WelcomeViewModel to use StateFlow for showIconState This commit refactors `WelcomeViewModel` to manage `showIconState` using a `StateFlow` directly from the `preferenceRepository`. Key changes: - `showIconState` is now a `StateFlow` initialized by `preferenceRepository.isHideInLauncher().stateIn(...)`. This provides a reactive stream directly from the data source. - The local `_showIconState` `MutableStateFlow` and its manual update in the `init` block have been removed. - `toggleShowIcon()` now reads the current state from `showIconState.value` to determine the new component enabled state for `MainActivityAlias`. - The call to `preferenceRepository.setShowIcon()` has been updated to reflect its new signature (likely now parameterless, inferring the state to toggle). --- .../liveinpeace/viewmodel/WelcomeViewModel.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt index 9775fa1..631e766 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt @@ -13,9 +13,12 @@ import com.maary.liveinpeace.service.ForegroundService import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -42,22 +45,25 @@ class WelcomeViewModel @Inject constructor( _isIgnoringBatteryOptimizations.value = powerManager.isIgnoringBatteryOptimizations(packageName) } - private val _showIconState = MutableStateFlow(false) - val showIconState = _showIconState.asStateFlow() + val showIconState: StateFlow = preferenceRepository.isHideInLauncher() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun toggleShowIcon() { viewModelScope.launch { + val newState = !showIconState.value + + // 1. 执行系统操作 val packageManager = application.packageManager val componentName = ComponentName(application, "${application.packageName}.MainActivityAlias") - - val newState = if(!_showIconState.value) { + val enabledState = if (newState) { PackageManager.COMPONENT_ENABLED_STATE_ENABLED } else { PackageManager.COMPONENT_ENABLED_STATE_DISABLED } - packageManager.setComponentEnabledSetting(componentName, newState, PackageManager.DONT_KILL_APP) - _showIconState.value = !_showIconState.value - preferenceRepository.setShowIcon(_showIconState.value) + packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) + + // 2. 将新状态通知 Repository + preferenceRepository.setShowIcon() } } @@ -69,9 +75,4 @@ class WelcomeViewModel @Inject constructor( startForegroundService(application, intent) } - init { - preferenceRepository.isShowingIcon().onEach { - _showIconState.value = it - }.launchIn(viewModelScope) - } } \ No newline at end of file From 939d7ded04985de48e1b0afe462261a86f68e587 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 21:54:55 +0800 Subject: [PATCH 26/48] Refactor: Add customizable ear protection threshold This commit introduces a customizable ear protection threshold feature, allowing users to define a safe volume range. Key changes include: - **Settings Screen:** - Added a `ThresholdSlider` composable in `SettingsScreen.kt` to allow users to set the minimum and maximum safe volume levels. - The slider uses a custom `ValueIndicatorThumb` for better visual feedback. - **Preference Repository:** - Introduced new preference keys (`PREF_EAR_PROTECTION_THRESHOLD_MAX`, `PREF_EAR_PROTECTION_THRESHOLD_MIN`) and constants (`EAR_PROTECTION_LOWER_THRESHOLD`, `EAR_PROTECTION_UPPER_THRESHOLD`) in `Constants.kt`. - Added functions in `PreferenceRepository.kt` (`getEarProtectionThreshold`, `setEarProtectionThreshold`) to read and write the threshold range using `DataStore`. - **Settings ViewModel:** - Added a `StateFlow` (`earProtectionThreshold`) in `SettingsViewModel.kt` to expose the current threshold range to the UI. - Implemented `setEarProtectionThreshold` function to update the stored threshold. - **Foreground Service:** - Modified `ForegroundService.kt` to fetch the user-defined ear protection threshold from `PreferenceRepository` before applying volume adjustments. - Removed hardcoded threshold values. - **String Resources:** - Added a new string resource `safe_volume_threshold` in `strings.xml`. --- .../java/com/maary/liveinpeace/Constants.kt | 5 + .../database/PreferenceRepository.kt | 36 +++++- .../liveinpeace/service/ForegroundService.kt | 10 +- .../ui/screen/SettingsComponents.kt | 107 ++++++++++++++++++ .../liveinpeace/ui/screen/SettingsScreen.kt | 7 ++ .../viewmodel/SettingsViewModel.kt | 17 ++- app/src/main/res/values/strings.xml | 1 + 7 files changed, 176 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index b7da454..3cc6012 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -19,6 +19,11 @@ class Constants { // SharedPreferences key for service running state const val PREF_SERVICE_RUNNING = "service_running_state" 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_EAR_PROTECTION_THRESHOLD = "ear_protection_threshold" + const val EAR_PROTECTION_LOWER_THRESHOLD = 10 + const val EAR_PROTECTION_UPPER_THRESHOLD = 25 // 设置通知 id const val ID_NOTIFICATION_SETTINGS = 3 // 前台通知 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 e302308..d07ee5a 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -19,6 +19,7 @@ import com.maary.liveinpeace.Constants.Companion.PREF_WELCOME_FINISHED import com.maary.liveinpeace.Constants.Companion.SHARED_PREF import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -39,6 +40,8 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont val PREF_WELCOME_FINISHED = booleanPreferencesKey(Constants.PREF_WELCOME_FINISHED) val PREF_SERVICE_RUNNING = booleanPreferencesKey(Constants.PREF_SERVICE_RUNNING) val PREF_HIDE_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) } fun getWatchingState(): Flow { @@ -108,9 +111,38 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont } } - suspend fun setShowIcon(state: Boolean) { + suspend fun setShowIcon() { datastore.edit { pref -> - pref[PREF_HIDE_IN_LAUNCHER] = !state + val currentState = pref[PREF_HIDE_IN_LAUNCHER] ?: false + pref[PREF_HIDE_IN_LAUNCHER] = !currentState + } + } + + private fun getEarProtectionThresholdMax() : Flow { + return datastore.data.map { pref -> + pref[PREF_EAR_PROTECTION_THRESHOLD_MAX] ?: Constants.EAR_PROTECTION_UPPER_THRESHOLD + } + } + + private fun getEarProtectionThresholdMin() : Flow { + return datastore.data.map { pref -> + pref[PREF_EAR_PROTECTION_THRESHOLD_MIN] ?: Constants.EAR_PROTECTION_LOWER_THRESHOLD + } + } + + fun getEarProtectionThreshold(): Flow { + return combine( + getEarProtectionThresholdMin(), + getEarProtectionThresholdMax() + ) { min, max -> + min..max + } + } + + suspend fun setEarProtectionThreshold(range: IntRange) { + datastore.edit { pref -> + pref[PREF_EAR_PROTECTION_THRESHOLD_MIN] = range.first + pref[PREF_EAR_PROTECTION_THRESHOLD_MAX] = range.last } } } \ 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 1772057..adc934c 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -21,6 +21,8 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import com.maary.liveinpeace.Constants +import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_LOWER_THRESHOLD +import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_UPPER_THRESHOLD import com.maary.liveinpeace.DeviceTimer import com.maary.liveinpeace.activity.MainActivity import com.maary.liveinpeace.R @@ -217,8 +219,6 @@ class ForegroundService : Service() { */ private val audioDeviceCallback = object : AudioDeviceCallback() { private val CALLBACK_TAG = "AudioDeviceCallback" - private val EAR_PROTECTION_LOWER_THRESHOLD = 10 - private val EAR_PROTECTION_UPPER_THRESHOLD = 25 private val VOLUME_ADJUST_ATTEMPTS = 100 private val IGNORED_DEVICE_TYPES = setOf( @@ -348,12 +348,14 @@ class ForegroundService : Service() { Log.d(CALLBACK_TAG, "Applying ear protection for $deviceName") var protectionApplied = false + val threshold = preferenceRepository.getEarProtectionThreshold().first() + // 调整音量到安全范围 - while (getVolumePercentage() > EAR_PROTECTION_UPPER_THRESHOLD && isActive) { + while (getVolumePercentage() > threshold.last && isActive) { audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) protectionApplied = true } - while (getVolumePercentage() < EAR_PROTECTION_LOWER_THRESHOLD && isActive) { + while (getVolumePercentage() < threshold.first && isActive) { audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) protectionApplied = true } diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index a70afaf..72ecc3f 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -1,5 +1,8 @@ package com.maary.liveinpeace.ui.screen +import android.transition.Slide +import android.util.Range +import androidx.annotation.FloatRange import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -8,8 +11,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -18,9 +25,13 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RangeSlider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,9 +39,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.util.toRange import com.maary.liveinpeace.ui.theme.Typography import com.maary.liveinpeace.R @@ -160,6 +175,98 @@ fun DropdownRow(options: MutableList, position: Int, onItemClicked: (Int } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThresholdSlider(title: String, range: IntRange, onValueChangeFinished: (IntRange) -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = title, + style = Typography.titleMedium, + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp) + ) + + // 内部状态的初始化逻辑保持不变 + var sliderPosition by remember { mutableStateOf(range.first.toFloat()..range.last.toFloat()) } + + // ✨ 新增 LaunchedEffect 来同步外部和内部的状态 + // 当 `range` 参数发生变化时,这个代码块会重新执行 + LaunchedEffect(range) { + sliderPosition = range.first.toFloat()..range.last.toFloat() + } + + RangeSlider( + value = sliderPosition, + steps = 0, + onValueChange = { newRange -> + // onValueChange 负责在用户拖动时更新UI,这部分是正确的 + sliderPosition = newRange + }, + valueRange = 0f..50f, + onValueChangeFinished = { + val intStart = sliderPosition.start.toInt() + val intEnd = sliderPosition.endInclusive.toInt() + onValueChangeFinished(intStart..intEnd) + }, + startThumb = { + // 2. 将自定义滑块应用于起始点 + ValueIndicatorThumb( + value = sliderPosition.start, + enabled = true + ) + }, + endThumb = { + // 3. 将自定义滑块应用于结束点 + ValueIndicatorThumb( + value = sliderPosition.endInclusive, + enabled = true + ) + } + ) + } +} + +@Composable +private fun ValueIndicatorThumb( + value: Float, + enabled: Boolean +) { + // 根据可用状态选择颜色 + val indicatorColor = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + val textColor = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.surface + + // 使用 Column 垂直排列指示器和滑块 + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // 4. 数值指示器 (气泡) + Surface( + color = indicatorColor, + shape = RoundedCornerShape(12.dp), // MD3 风格的圆角 + modifier = Modifier + .padding(bottom = 6.dp) // 指示器和滑块之间的间距 + ) { + Text( + text = "%.0f".format(value), // 将数值格式化为整数 + color = textColor, + style = MaterialTheme.typography.labelSmall, // 使用 MD3 的字体样式 + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + // 5. 滑块本身 (圆点) + Box( + modifier = Modifier + .height(35.dp) // MD3 默认的滑块大小 + .width(4.dp) + .background(color = SliderDefaults.colors().thumbColor) + ) + } +} + @Composable fun TopBox(content: @Composable () -> Unit) { Box( 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 e4ac6b8..f89e822 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 @@ -5,6 +5,7 @@ import android.app.Activity import android.content.Intent import android.os.Build import android.provider.Settings +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable @@ -156,6 +157,12 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { onCheckedChange = { settingsViewModel.protectionSwitch() } ) + ThresholdSlider( + title = stringResource(id = R.string.safe_volume_threshold), + range = settingsViewModel.earProtectionThreshold.collectAsState().value, + onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, + ) + SwitchRow( title = stringResource(R.string.show_icon), description = stringResource(R.string.show_icon_description), 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 f80f852..353bcc7 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -91,7 +91,22 @@ class SettingsViewModel @Inject constructor( packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) // 2. 将新状态通知 Repository - preferenceRepository.setShowIcon(newState) + preferenceRepository.setShowIcon() + } + } + + val earProtectionThreshold: StateFlow = preferenceRepository.getEarProtectionThreshold() + .stateIn( + scope = viewModelScope, + // 当 UI 订阅时开始收集数据,并在最后一个订阅者消失 5 秒后停止,以节省资源 + started = SharingStarted.WhileSubscribed(5000), + // 提供一个初始值,它只在仓库的真实值返回之前短暂使用 + initialValue = EAR_PROTECTION_LOWER_THRESHOLD..EAR_PROTECTION_UPPER_THRESHOLD + ) + + fun setEarProtectionThreshold(range: IntRange) { + viewModelScope.launch { + preferenceRepository.setEarProtectionThreshold(range) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a3e738..d208fa6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,4 +68,5 @@ Show App Icon in Launcher Tapping the icon will take you to the settings page. WelcomeActivity + Safe Volume Threshold \ No newline at end of file From 7de940215a903af99b5385620b04697eee947b85 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:05:05 +0800 Subject: [PATCH 27/48] Refactor service state handling and add auto-restart logic This commit introduces a static `isRunning` flag in `ForegroundService` to track its active state in memory. Key changes: - **ForegroundService:** - Added a public, volatile `isRunning` static boolean variable, updated in `onCreate()` and `onDestroy()`. - **QSTileService:** - In `onStartListening()`, the tile state is now synchronized with `ForegroundService.isRunning`. - If the persisted state indicates the service should be running but `ForegroundService.isRunning` is false (suggesting a system kill), the persisted state is corrected to false, and the tile is updated accordingly. - **SettingsViewModel:** - A new `checkAndSyncServiceState()` function is called during initialization. - This function compares the persisted service switch state with `ForegroundService.isRunning`. - If the persisted state is "on" but the service is not running in memory, the service is automatically restarted. --- .../liveinpeace/service/ForegroundService.kt | 7 ++++++ .../liveinpeace/service/QSTileService.kt | 24 ++++++++++++++++--- .../viewmodel/SettingsViewModel.kt | 21 ++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) 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 adc934c..db07e52 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -60,6 +60,11 @@ import java.util.concurrent.ConcurrentHashMap class ForegroundService : Service() { companion object { + + @Volatile + var isRunning = false + private set + private const val TAG = "ForegroundService" // 为 PendingIntent 定义请求码常量,避免使用魔法数字 @@ -98,6 +103,7 @@ class ForegroundService : Service() { // 启动前台服务,并立即更新一次通知状态 startForegroundWithNotification() setServiceRunningState(true) + isRunning = true Log.d(TAG, "Service created successfully.") } @@ -147,6 +153,7 @@ class ForegroundService : Service() { override fun onDestroy() { Log.d(TAG, "Service destroying...") + isRunning=false // 在清理资源前,先更新服务状态 setServiceRunningState(false) diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index d7440a4..a7ad640 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -108,14 +108,32 @@ class QSTileService: TileService() { @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onStartListening() { super.onStartListening() - // Update tile based on persisted state initially + + // --- 核心校准逻辑开始 --- + // 每次磁贴可见时,检查持久化状态和内存状态是否一致 serviceScope.launch { - updateTileState(preferenceRepository.isServiceRunning().first()) + // 从 PreferenceRepository 读取“预期状态” + val expectedState = preferenceRepository.isServiceRunning().first() + // 直接从内存中读取服务的“实际状态” + val actualState = ForegroundService.isRunning + // 对比两个状态 + if (expectedState && !actualState) { + // **发现不一致!** + // 记录显示服务在运行,但内存中它已停止。 + // 这几乎可以肯定是服务被系统强杀了。 + // 1. 修正错误的持久化记录 + preferenceRepository.setServiceRunning(false) + // 2. 用修正后的、正确的状态(false)来更新磁贴外观 + updateTileState(false) + } else { + // 状态一致,一切正常。直接按预期状态更新磁贴即可。 + updateTileState(expectedState) + } } + // --- 核心校准逻辑结束 --- val intentFilter = IntentFilter() intentFilter.addAction(Constants.BROADCAST_ACTION_FOREGROUND) - // Use RECEIVER_NOT_EXPORTED for security with internal broadcasts registerReceiver(foregroundServiceReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } 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 353bcc7..f4b269b 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -41,6 +42,22 @@ class SettingsViewModel @Inject constructor( } } + private fun checkAndSyncServiceState() { + viewModelScope.launch { + // 获取预期的状态(来自持久化存储) + val expectedState = foregroundSwitchState.first() + + // 获取服务的真实状态(来自内存) + val actualState = ForegroundService.isRunning + + // 如果预期“开启”,但服务实际“停止”,说明服务曾被强杀 + if (expectedState && !actualState) { + // -> 自动重新启动服务,以恢复到用户想要的开启状态 + startForegroundService() + } + } + } + private fun startForegroundService() { viewModelScope.launch { val intent = Intent(application, ForegroundService::class.java) @@ -110,4 +127,8 @@ class SettingsViewModel @Inject constructor( } } + init { + checkAndSyncServiceState() + } + } \ No newline at end of file From d38cdbf64b3278aaf0bfda5148534e65ac5da217 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:03:49 +0800 Subject: [PATCH 28/48] Refactor icon visibility preference logic This commit refactors the logic for managing the app icon's visibility in the launcher. - Renamed `PREF_HIDE_IN_LAUNCHER` to `PREF_VISIBLE_IN_LAUNCHER` in `PreferenceRepository` for clarity. - Updated `PreferenceRepository` methods: - `isHideInLauncher()` renamed to `isIconShown()`. - `setHideInLauncher()` and `setShowIcon()` consolidated into `toggleIconVisibility()`. - Adjusted `WelcomeViewModel` and `SettingsViewModel` to use the updated `PreferenceRepository` methods for managing icon visibility state and toggling. - Removed unused imports in `WelcomeViewModel` and `SettingsViewModel`. --- .../database/PreferenceRepository.kt | 32 ++++--------------- .../viewmodel/SettingsViewModel.kt | 10 ++---- .../liveinpeace/viewmodel/WelcomeViewModel.kt | 6 ++-- 3 files changed, 10 insertions(+), 38 deletions(-) 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 d07ee5a..a7729e2 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -9,13 +9,6 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.maary.liveinpeace.Constants -import com.maary.liveinpeace.Constants.Companion.MODE_IMG -import com.maary.liveinpeace.Constants.Companion.MODE_NUM -import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION -import com.maary.liveinpeace.Constants.Companion.PREF_ICON -import com.maary.liveinpeace.Constants.Companion.PREF_SERVICE_RUNNING -import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME -import com.maary.liveinpeace.Constants.Companion.PREF_WELCOME_FINISHED import com.maary.liveinpeace.Constants.Companion.SHARED_PREF import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -39,7 +32,7 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont val PREF_ENABLE_EAR_PROTECTION = booleanPreferencesKey(Constants.PREF_ENABLE_EAR_PROTECTION) val PREF_WELCOME_FINISHED = booleanPreferencesKey(Constants.PREF_WELCOME_FINISHED) val PREF_SERVICE_RUNNING = booleanPreferencesKey(Constants.PREF_SERVICE_RUNNING) - val PREF_HIDE_IN_LAUNCHER = booleanPreferencesKey(Constants.PREF_HIDE_IN_LAUNCHER) + 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) } @@ -92,29 +85,16 @@ class PreferenceRepository @Inject constructor(@ApplicationContext context: Cont } } - fun isHideInLauncher() : Flow { + fun isIconShown() : Flow { return datastore.data.map { pref -> - pref[PREF_HIDE_IN_LAUNCHER] ?: false + pref[PREF_VISIBLE_IN_LAUNCHER] ?: false } } - suspend fun setHideInLauncher(state: Boolean) { + suspend fun toggleIconVisibility() { datastore.edit { pref -> - pref[PREF_HIDE_IN_LAUNCHER] = state - } - } - - fun isShowingIcon(): Flow { - return datastore.data.map { pref -> - val isHidden = pref[PREF_HIDE_IN_LAUNCHER] ?: true - !isHidden - } - } - - suspend fun setShowIcon() { - datastore.edit { pref -> - val currentState = pref[PREF_HIDE_IN_LAUNCHER] ?: false - pref[PREF_HIDE_IN_LAUNCHER] = !currentState + val currentState = pref[PREF_VISIBLE_IN_LAUNCHER] ?: false + pref[PREF_VISIBLE_IN_LAUNCHER] = !currentState } } 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 f4b269b..139000b 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -4,7 +4,6 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_LOWER_THRESHOLD @@ -14,14 +13,9 @@ import com.maary.liveinpeace.service.ForegroundService import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -90,7 +84,7 @@ class SettingsViewModel @Inject constructor( } } - val showIconState: StateFlow = preferenceRepository.isHideInLauncher() + val showIconState: StateFlow = preferenceRepository.isIconShown() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun toggleShowIcon() { @@ -108,7 +102,7 @@ class SettingsViewModel @Inject constructor( packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) // 2. 将新状态通知 Repository - preferenceRepository.setShowIcon() + preferenceRepository.toggleIconVisibility() } } diff --git a/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt index 631e766..5e47d62 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt @@ -16,8 +16,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -45,7 +43,7 @@ class WelcomeViewModel @Inject constructor( _isIgnoringBatteryOptimizations.value = powerManager.isIgnoringBatteryOptimizations(packageName) } - val showIconState: StateFlow = preferenceRepository.isHideInLauncher() + val showIconState: StateFlow = preferenceRepository.isIconShown() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) fun toggleShowIcon() { @@ -63,7 +61,7 @@ class WelcomeViewModel @Inject constructor( packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) // 2. 将新状态通知 Repository - preferenceRepository.setShowIcon() + preferenceRepository.toggleIconVisibility() } } From c6f361020f83debfe444120c721ccd24f243af62 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:07:44 +0800 Subject: [PATCH 29/48] Animate ear protection threshold slider visibility The `ThresholdSlider` for setting the ear protection threshold is now wrapped in an `AnimatedVisibility` composable. This means the slider will only be visible and animate in when the "Ear Protection" switch is enabled, and animate out when disabled. --- .../liveinpeace/ui/screen/SettingsScreen.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) 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 f89e822..5768b79 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 @@ -8,6 +8,12 @@ import android.provider.Settings import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -132,6 +138,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { * 4. 隐藏桌面图标 * */ + val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() + EnableForegroundRow( state = settingsViewModel.foregroundSwitchState.collectAsState().value, onCheckedChange = { settingsViewModel.foregroundSwitch() }, @@ -153,15 +161,18 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { SwitchRow( title = stringResource(R.string.protection), description = stringResource(R.string.protection) /* todo */, - state = settingsViewModel.protectionSwitchState.collectAsState().value, + state = isProtectionOn, onCheckedChange = { settingsViewModel.protectionSwitch() } ) - ThresholdSlider( - title = stringResource(id = R.string.safe_volume_threshold), - range = settingsViewModel.earProtectionThreshold.collectAsState().value, - onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, - ) + // 使用 AnimatedVisibility 包裹需要条件显示的组件 + AnimatedVisibility(visible = isProtectionOn) { + ThresholdSlider( + title = stringResource(id = R.string.safe_volume_threshold), + range = settingsViewModel.earProtectionThreshold.collectAsState().value, + onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, + ) + } SwitchRow( title = stringResource(R.string.show_icon), From 916607747d0a7e7a52d38078c97a86a392051f91 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:19:38 +0800 Subject: [PATCH 30/48] Refactor: Consolidate settings item containers This commit introduces a unified `SettingsItem` composable to replace the individual `TopBox`, `MiddleBox`, `BottomBox`, and `StandaloneBox` composables. The new `SettingsItem` composable takes a `GroupPosition` enum (TOP, MIDDLE, BOTTOM, SINGLE) to determine the appropriate corner rounding for the settings item, simplifying the layout code and improving maintainability. The `WelcomeScreen.kt` has been updated to use the new `SettingsItem` composable with the corresponding `GroupPosition`. --- .../ui/screen/SettingsComponents.kt | 60 ++++++------------- .../liveinpeace/ui/screen/WelcomeScreen.kt | 6 +- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 72ecc3f..37dd96a 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -267,55 +267,33 @@ private fun ValueIndicatorThumb( } } -@Composable -fun TopBox(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 2.dp) - .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow), - contentAlignment = Alignment.Center - ) { - content() - } -} - -@Composable -fun MiddleBox(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 2.dp) - .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 4.dp, bottomEnd = 4.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow), - contentAlignment = Alignment.Center - ) { - content() - } +// 定义设置项在分组中的位置 +enum class GroupPosition { + TOP, // 顶部 + MIDDLE, // 中间 + BOTTOM, // 底部 + SINGLE // 独立,自成一组 } @Composable -fun BottomBox(content: @Composable () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 2.dp) - .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow), - contentAlignment = Alignment.Center - ) { - content() +fun SettingsItem( + position: GroupPosition, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + // 根据 position 决定圆角形状 + val shape = when (position) { + GroupPosition.TOP -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp) + GroupPosition.MIDDLE -> RoundedCornerShape(4.dp) + GroupPosition.BOTTOM -> RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp) + GroupPosition.SINGLE -> RoundedCornerShape(24.dp) // 上下都是大圆角 } -} -@Composable -fun StandaloneBox(content: @Composable () -> Unit) { Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 2.dp) - .clip(RoundedCornerShape(24.dp)) + .clip(shape) // 动态应用形状 .background(MaterialTheme.colorScheme.surfaceContainerLow), contentAlignment = Alignment.Center ) { diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt index b3e79fa..de814e2 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -131,7 +131,7 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { fontWeight = FontWeight.Bold, ) ) - StandaloneBox { + SettingsItem(GroupPosition.SINGLE) { SwitchRow( title = stringResource(R.string.notification_permission), description = stringResource(R.string.notification_permission_description), @@ -147,7 +147,7 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { fontWeight = FontWeight.Bold ) ) - TopBox { + SettingsItem(GroupPosition.TOP) { SwitchRow( title = stringResource(R.string.disable_battery_optimization), description = stringResource(R.string.disable_battery_optimization_description), @@ -160,7 +160,7 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { context.startActivity(batteryIntent) } } - BottomBox { + SettingsItem(GroupPosition.BOTTOM) { SwitchRow( title = stringResource(R.string.show_icon), description = stringResource(R.string.show_icon_description), From dc950bf1b9273ee67be9d5ea5f7e2fb1ebae4237 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Tue, 17 Jun 2025 19:44:37 +0800 Subject: [PATCH 31/48] Refactor settings UI and conditionally show notification settings This commit refactors the settings screen UI for better organization and introduces conditional visibility for notification settings. - The "Default Channel" (Enable Foreground Service) and "Notification Settings" are now grouped. - The "Notification Settings" item is only visible when the "Default Channel" switch is enabled. Clicking it opens the app's notification settings in the system settings. - The "Enable Watching", "Ear Protection", "Safe Volume Threshold", and "Show Icon in Launcher" settings are grouped together. - `SettingsItem` composable is used to visually group related settings. - A spacer is added at the bottom of the scrollable content to account for bottom padding. --- .../liveinpeace/ui/screen/SettingsScreen.kt | 94 ++++++++++++------- 1 file changed, 61 insertions(+), 33 deletions(-) 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 5768b79..1e78fef 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 @@ -20,8 +20,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState @@ -139,48 +141,74 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { * */ val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() + val isForegroundEnabled by settingsViewModel.foregroundSwitchState.collectAsState() - EnableForegroundRow( - state = settingsViewModel.foregroundSwitchState.collectAsState().value, - onCheckedChange = { settingsViewModel.foregroundSwitch() }, - onNotificationSettingsClicked = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivity(intent) - }, - ) + SettingsItem( position = if (isForegroundEnabled) GroupPosition.TOP else GroupPosition.SINGLE) { + SwitchRow( + title = stringResource(id = R.string.default_channel), + description = stringResource(id = R.string.default_channel_description), + state = isForegroundEnabled, + onCheckedChange = { settingsViewModel.foregroundSwitch() }) + } - SwitchRow( - title = stringResource(R.string.enable_watching), - description = stringResource(R.string.enable_watching) /* todo */, - state = settingsViewModel.alertSwitchState.collectAsState().value, - onCheckedChange = { settingsViewModel.alertSwitch() } - ) + AnimatedVisibility(visible = isForegroundEnabled) { + SettingsItem(GroupPosition.BOTTOM) { + TextContent( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + context.startActivity(intent) + } + .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + title = stringResource(id = R.string.notification_settings), + description = stringResource(R.string.notification_settings_description) + ) + } + } - SwitchRow( - title = stringResource(R.string.protection), - description = stringResource(R.string.protection) /* todo */, - state = isProtectionOn, - onCheckedChange = { settingsViewModel.protectionSwitch() } - ) + SettingsItem(GroupPosition.TOP) { + SwitchRow( + title = stringResource(R.string.enable_watching), + description = stringResource(R.string.enable_watching) /* todo */, + state = settingsViewModel.alertSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.alertSwitch() } + ) + } + + SettingsItem(GroupPosition.MIDDLE) { + SwitchRow( + title = stringResource(R.string.protection), + description = stringResource(R.string.protection) /* todo */, + state = isProtectionOn, + onCheckedChange = { settingsViewModel.protectionSwitch() } + ) + } // 使用 AnimatedVisibility 包裹需要条件显示的组件 AnimatedVisibility(visible = isProtectionOn) { - ThresholdSlider( - title = stringResource(id = R.string.safe_volume_threshold), - range = settingsViewModel.earProtectionThreshold.collectAsState().value, - onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, - ) + SettingsItem(GroupPosition.MIDDLE) { + ThresholdSlider( + title = stringResource(id = R.string.safe_volume_threshold), + range = settingsViewModel.earProtectionThreshold.collectAsState().value, + onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, + ) + } } - SwitchRow( - title = stringResource(R.string.show_icon), - description = stringResource(R.string.show_icon_description), - state = settingsViewModel.showIconState.collectAsState().value, - ) { - settingsViewModel.toggleShowIcon() + SettingsItem(GroupPosition.BOTTOM) { + SwitchRow( + title = stringResource(R.string.show_icon), + description = stringResource(R.string.show_icon_description), + state = settingsViewModel.showIconState.collectAsState().value, + ) { + settingsViewModel.toggleShowIcon() + } } + + Spacer(modifier = Modifier.height(innerPadding.calculateBottomPadding())) } } } \ No newline at end of file From 161b35c24131b5e94794cf04e718ee09618fc4f3 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Tue, 17 Jun 2025 19:47:44 +0800 Subject: [PATCH 32/48] Refactor foreground service control in SettingsViewModel This commit refactors the foreground service control logic in `SettingsViewModel.kt`. - Renamed `startForegroundService()` to `enableForegroundService()` and `stopForegroundService()` to `disableForegroundService()` for clarity. - These methods now update the `service_running` preference via `preferenceRepository.setServiceRunning()` to accurately reflect the desired service state. This ensures the service's running state is persisted even if the service is killed by the system. - The `foregroundSwitch()` and `checkForegroundService()` methods have been updated to use these new function names. --- .../maary/liveinpeace/viewmodel/SettingsViewModel.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 139000b..2c61601 100644 --- a/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -30,9 +30,9 @@ class SettingsViewModel @Inject constructor( fun foregroundSwitch() { if(!foregroundSwitchState.value) { - startForegroundService() + enableForegroundService() } else { - stopForegroundService() + disableForegroundService() } } @@ -47,22 +47,24 @@ class SettingsViewModel @Inject constructor( // 如果预期“开启”,但服务实际“停止”,说明服务曾被强杀 if (expectedState && !actualState) { // -> 自动重新启动服务,以恢复到用户想要的开启状态 - startForegroundService() + enableForegroundService() } } } - private fun startForegroundService() { + private fun enableForegroundService() { viewModelScope.launch { val intent = Intent(application, ForegroundService::class.java) application.startForegroundService(intent) + preferenceRepository.setServiceRunning(true) } } - private fun stopForegroundService() { + private fun disableForegroundService() { viewModelScope.launch { val intent = Intent(application, ForegroundService::class.java) application.stopService(intent) + preferenceRepository.setServiceRunning(false) } } From f9ae1f60deee0759a8ee2d502925cf097b564b25 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Tue, 17 Jun 2025 20:05:32 +0800 Subject: [PATCH 33/48] Add spacing in SettingsScreen This commit adds vertical spacing (16.dp) between some settings items in the `SettingsScreen` to improve visual separation. --- .../java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 1e78fef..67be307 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 @@ -143,6 +143,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() val isForegroundEnabled by settingsViewModel.foregroundSwitchState.collectAsState() + Spacer(modifier = Modifier.height(16.dp)) + SettingsItem( position = if (isForegroundEnabled) GroupPosition.TOP else GroupPosition.SINGLE) { SwitchRow( title = stringResource(id = R.string.default_channel), @@ -169,6 +171,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { } } + Spacer(modifier = Modifier.height(16.dp)) + SettingsItem(GroupPosition.TOP) { SwitchRow( title = stringResource(R.string.enable_watching), From c19a897838402f9303d08a32c34899798b0f934b Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:22:41 +0800 Subject: [PATCH 34/48] Refactor SettingsScreen UI and component styling This commit refactors the `SettingsScreen` and its components to improve visual consistency and user experience. Key changes include: - **TopAppBar Styling**: - Switched from `MediumTopAppBar` to `TopAppBar` with a `pinnedScrollBehavior`. - Updated `TopAppBar` colors to use `inversePrimary` for the container and `primary` for title and icons. - Applied a custom style (bold, italic) to the app name in the `TopAppBar`. - **Settings Item Styling**: - Introduced `containerColor` parameter to `SettingsItem` for better theming flexibility. - Set specific container colors for different groups of settings (e.g., `tertiaryContainer` for foreground service settings, `secondaryContainer` for watching and protection settings). - Updated `SettingsItem` background to use `inversePrimary`. - **Switch Component Styling**: - Added `switchColor` parameter to `SwitchRow` to customize switch track color. - `SwitchRow` description is now optional. - **Slider Component Styling**: - `RangeSlider` now fills the available width. - Updated `RangeSlider` and `SliderValueIndicator` colors to align with the new theme (using `secondary` and `onSecondary` colors). - Adjusted padding and width for `SliderValueIndicator` text for better layout. - **Layout Adjustments**: - Added top padding to the main `Column` in `SettingsScreen` to account for the `TopAppBar`. - Removed `EnableForegroundRow` as its functionality is now integrated into `SettingsItem` and `SwitchRow`. - Added a `Spacer` between the text content and the switch in `SwitchRow` for better visual separation. - **Material3 Version**: - Updated Material3 dependency to `1.4.0-alpha15`. --- app/build.gradle | 2 +- .../ui/screen/SettingsComponents.kt | 69 +++++++++---------- .../liveinpeace/ui/screen/SettingsScreen.kt | 48 +++++++++---- 3 files changed, 67 insertions(+), 52 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 67eb439..3bcb063 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,7 +105,7 @@ dependencies { implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material3:material3:1.4.0-alpha15' androidTestImplementation platform('androidx.compose:compose-bom:2025.06.00') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 37dd96a..16c4e04 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -29,6 +30,7 @@ import androidx.compose.material3.RangeSlider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,6 +45,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.util.toRange @@ -50,17 +53,19 @@ import com.maary.liveinpeace.ui.theme.Typography import com.maary.liveinpeace.R @Composable -fun TextContent(modifier: Modifier = Modifier, title: String, description: String) { +fun TextContent(modifier: Modifier = Modifier, title: String, description: String? = null) { Column(modifier = modifier){ Text( title, style = Typography.titleLarge ) - Text( - description, - style = Typography.bodySmall, - maxLines = 5 - ) + if (description != null) { + Text( + description, + style = Typography.bodySmall, + maxLines = 5 + ) + } } } @@ -114,8 +119,9 @@ fun DropdownItem(modifier: Modifier, options: MutableList, position: Int @Composable fun SwitchRow( title: String, - description: String, + description: String? = null, state: Boolean, + switchColor: Color = MaterialTheme.colorScheme.secondary, onCheckedChange: (Boolean) -> Unit ) { Row( @@ -127,31 +133,8 @@ fun SwitchRow( verticalAlignment = Alignment.CenterVertically ) { TextContent(modifier = Modifier.weight(1f), title = title, description = description) - Switch(checked = state, onCheckedChange = onCheckedChange) - } -} - -@Composable -fun EnableForegroundRow( - state: Boolean, - onCheckedChange: (Boolean) -> Unit, - onNotificationSettingsClicked: () -> Unit -) { - Column { - SwitchRow( - title = stringResource(id = R.string.default_channel), - description = stringResource(id = R.string.default_channel_description), - state = state, - onCheckedChange = onCheckedChange) - if (state) { - TextContent( - modifier = Modifier - .fillMaxWidth() - .clickable { onNotificationSettingsClicked() } - .padding(start = 32.dp, top = 8.dp, end = 32.dp, bottom = 8.dp), - title = stringResource(id = R.string.notification_settings), - description = stringResource(R.string.notification_settings_description)) - } + Spacer(modifier = Modifier.width(8.dp)) + Switch(checked = state, onCheckedChange = onCheckedChange, colors = SwitchDefaults.colors(checkedTrackColor = switchColor)) } } @@ -201,6 +184,7 @@ fun ThresholdSlider(title: String, range: IntRange, onValueChangeFinished: (IntR } RangeSlider( + modifier = Modifier.fillMaxWidth(), value = sliderPosition, steps = 0, onValueChange = { newRange -> @@ -226,7 +210,16 @@ fun ThresholdSlider(title: String, range: IntRange, onValueChangeFinished: (IntR value = sliderPosition.endInclusive, enabled = true ) - } + }, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.secondary, +// thumbColor = MaterialTheme.colorScheme.secondary, + inactiveTrackColor = MaterialTheme.colorScheme.onSecondary, +// activeTickColor = MaterialTheme.colorScheme.onSecondaryContainer, +// inactiveTickColor = MaterialTheme.colorScheme.onSecondaryContainer, +// disabledActiveTickColor = MaterialTheme.colorScheme.onSecondaryContainer, +// disabledInactiveTickColor = MaterialTheme.colorScheme.onSecondaryContainer + ) ) } } @@ -237,8 +230,8 @@ private fun ValueIndicatorThumb( enabled: Boolean ) { // 根据可用状态选择颜色 - val indicatorColor = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - val textColor = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.surface + val indicatorColor = if (enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + val textColor = if (enabled) MaterialTheme.colorScheme.onSecondary else MaterialTheme.colorScheme.surface // 使用 Column 垂直排列指示器和滑块 Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -253,7 +246,8 @@ private fun ValueIndicatorThumb( text = "%.0f".format(value), // 将数值格式化为整数 color = textColor, style = MaterialTheme.typography.labelSmall, // 使用 MD3 的字体样式 - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + textAlign = TextAlign.Center, + modifier = Modifier.width(24.dp).padding(horizontal = 4.dp, vertical = 2.dp) ) } @@ -279,6 +273,7 @@ enum class GroupPosition { fun SettingsItem( position: GroupPosition, modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, content: @Composable () -> Unit ) { // 根据 position 决定圆角形状 @@ -294,7 +289,7 @@ fun SettingsItem( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 2.dp) .clip(shape) // 动态应用形状 - .background(MaterialTheme.colorScheme.surfaceContainerLow), + .background(containerColor), contentAlignment = Alignment.Center ) { content() 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 67be307..579fcfd 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 @@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement @@ -43,6 +44,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -59,6 +61,9 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.maary.liveinpeace.ui.theme.Typography import com.maary.liveinpeace.R @@ -73,7 +78,7 @@ import com.maary.liveinpeace.activity.HistoryActivity @Composable fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val context = LocalContext.current if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -91,13 +96,20 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Scaffold ( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - MediumTopAppBar( + TopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, + containerColor = MaterialTheme.colorScheme.inversePrimary, titleContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary ), title = { - Text(stringResource(R.string.app_name)) + Text(stringResource(R.string.app_name), + style = Typography.titleLarge.copy( + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic + ) + ) }, navigationIcon = { IconButton(onClick = { @@ -128,13 +140,14 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Column( modifier = Modifier .fillMaxSize() - .padding(top = innerPadding.calculateTopPadding()) .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.inversePrimary) ){ /** * 1. 启用状态 * 2. 提醒状态 + * 2.1 提醒时间 todo * 3. 音量保护 * 3.1 安全音量阈值 * 4. 隐藏桌面图标 @@ -143,18 +156,21 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() val isForegroundEnabled by settingsViewModel.foregroundSwitchState.collectAsState() - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp + innerPadding.calculateTopPadding())) - SettingsItem( position = if (isForegroundEnabled) GroupPosition.TOP else GroupPosition.SINGLE) { + SettingsItem( + position = if (isForegroundEnabled) GroupPosition.TOP else GroupPosition.SINGLE, + containerColor = MaterialTheme.colorScheme.tertiaryContainer) { SwitchRow( title = stringResource(id = R.string.default_channel), - description = stringResource(id = R.string.default_channel_description), state = isForegroundEnabled, - onCheckedChange = { settingsViewModel.foregroundSwitch() }) + onCheckedChange = { settingsViewModel.foregroundSwitch() }, + switchColor = MaterialTheme.colorScheme.tertiary) } AnimatedVisibility(visible = isForegroundEnabled) { - SettingsItem(GroupPosition.BOTTOM) { + SettingsItem( position = GroupPosition.BOTTOM, + containerColor = MaterialTheme.colorScheme.tertiaryContainer) { TextContent( modifier = Modifier .fillMaxWidth() @@ -173,7 +189,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { Spacer(modifier = Modifier.height(16.dp)) - SettingsItem(GroupPosition.TOP) { + SettingsItem(GroupPosition.TOP, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( title = stringResource(R.string.enable_watching), description = stringResource(R.string.enable_watching) /* todo */, @@ -182,7 +199,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { ) } - SettingsItem(GroupPosition.MIDDLE) { + SettingsItem(GroupPosition.MIDDLE, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( title = stringResource(R.string.protection), description = stringResource(R.string.protection) /* todo */, @@ -193,7 +211,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { // 使用 AnimatedVisibility 包裹需要条件显示的组件 AnimatedVisibility(visible = isProtectionOn) { - SettingsItem(GroupPosition.MIDDLE) { + SettingsItem(GroupPosition.MIDDLE, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { ThresholdSlider( title = stringResource(id = R.string.safe_volume_threshold), range = settingsViewModel.earProtectionThreshold.collectAsState().value, @@ -202,7 +221,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { } } - SettingsItem(GroupPosition.BOTTOM) { + SettingsItem(GroupPosition.BOTTOM, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( title = stringResource(R.string.show_icon), description = stringResource(R.string.show_icon_description), From c4fdc4ae8cd5b01948f08743d03cc87acc632cf5 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:19:22 +0800 Subject: [PATCH 35/48] Refactor: Update TextContent to use tertiary color This commit updates the `TextContent` composable function to allow specifying a custom color. - The `TextContent` function now accepts an optional `color` parameter, which defaults to `MaterialTheme.colorScheme.secondary`. - In `SettingsScreen.kt`, the "Notification settings" `TextContent` now uses `MaterialTheme.colorScheme.tertiary` for its text. - The `SettingsToggleComponent` now passes its `switchColor` to the `TextContent` composable for consistent coloring. --- .../maary/liveinpeace/ui/screen/SettingsComponents.kt | 10 ++++++---- .../com/maary/liveinpeace/ui/screen/SettingsScreen.kt | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 16c4e04..7c01178 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -53,17 +53,19 @@ import com.maary.liveinpeace.ui.theme.Typography import com.maary.liveinpeace.R @Composable -fun TextContent(modifier: Modifier = Modifier, title: String, description: String? = null) { +fun TextContent(modifier: Modifier = Modifier, title: String, description: String? = null, color: Color = MaterialTheme.colorScheme.secondary) { Column(modifier = modifier){ Text( title, - style = Typography.titleLarge + style = Typography.titleLarge, + color = color, ) if (description != null) { Text( description, style = Typography.bodySmall, - maxLines = 5 + maxLines = 5, + color = color ) } } @@ -132,7 +134,7 @@ fun SwitchRow( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - TextContent(modifier = Modifier.weight(1f), title = title, description = description) + TextContent(modifier = Modifier.weight(1f), title = title, description = description, color = switchColor) Spacer(modifier = Modifier.width(8.dp)) Switch(checked = state, onCheckedChange = onCheckedChange, colors = SwitchDefaults.colors(checkedTrackColor = switchColor)) } 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 579fcfd..01b60d4 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 @@ -182,7 +182,8 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { } .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), title = stringResource(id = R.string.notification_settings), - description = stringResource(R.string.notification_settings_description) + description = stringResource(R.string.notification_settings_description), + color = MaterialTheme.colorScheme.tertiary ) } } From 0728dbf325c55dfb1505643ab95ae30d3458187e Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:43:39 +0800 Subject: [PATCH 36/48] Refactor WelcomeScreen UI and navigation This commit introduces several changes to the `WelcomeScreen`: - **UI Update:** - The TopAppBar and background colors are updated to use `inversePrimary` and `tertiaryContainer` from the MaterialTheme. - Text color for section titles ("Necessary Permissions", "Optional Permissions") is set to `MaterialTheme.colorScheme.primary`. - The switch color for the notification permission is set to `MaterialTheme.colorScheme.tertiary`. - Added padding to the top of the content. - **Navigation Change:** - Upon clicking the "Finish" button, instead of finishing the current activity, the app now navigates to `MainActivity`. - **Permission Request:** - The `WelcomeScreen` now requests the `POST_NOTIFICATIONS` permission, which is necessary for Android 13 and higher to display notifications. --- .../liveinpeace/ui/screen/WelcomeScreen.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt index de814e2..111a8fd 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -53,6 +54,8 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.maary.liveinpeace.activity.MainActivity +import com.maary.liveinpeace.ui.theme.Typography @SuppressLint("BatteryLife") @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -101,8 +104,9 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { topBar = { LargeTopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary), + containerColor = MaterialTheme.colorScheme.inversePrimary, + titleContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary), title = { Text(stringResource(R.string.welcome)) }, @@ -119,23 +123,26 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { } ) { innerPadding -> Column( - modifier = Modifier.padding(top = innerPadding.calculateTopPadding()) - .background(MaterialTheme.colorScheme.surfaceContainerLowest) + modifier = Modifier + .background(MaterialTheme.colorScheme.inversePrimary) .fillMaxSize() ) { + Spacer(modifier = Modifier.height(16.dp + innerPadding.calculateTopPadding())) Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = stringResource(R.string.nessery_permissions), style = TextStyle( fontWeight = FontWeight.Bold, - ) + ), + color = MaterialTheme.colorScheme.primary ) - SettingsItem(GroupPosition.SINGLE) { + SettingsItem(GroupPosition.SINGLE , containerColor = MaterialTheme.colorScheme.tertiaryContainer) { SwitchRow( title = stringResource(R.string.notification_permission), description = stringResource(R.string.notification_permission_description), state = hasNotificationPermission, + switchColor = MaterialTheme.colorScheme.tertiary ) { permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } @@ -145,7 +152,8 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { text = stringResource(R.string.optional_permissions), style = TextStyle( fontWeight = FontWeight.Bold - ) + ), + color = MaterialTheme.colorScheme.primary ) SettingsItem(GroupPosition.TOP) { SwitchRow( @@ -177,7 +185,9 @@ fun WelcomeScreen(welcomeViewModel: WelcomeViewModel = viewModel()) { enabled = hasNotificationPermission, onClick = { welcomeViewModel.welcomeFinished() - (context as? Activity)?.finish() +// (context as? Activity)?.finish() + val intent = Intent(context, MainActivity::class.java) + context.startActivity(intent) }) { Text(stringResource(R.string.finish)) } From c43c49ee7c1daf598a3a22923fd23d9f2301de7d Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:17:23 +0800 Subject: [PATCH 37/48] Enable `generateLocaleConfig` and specify `unqualifiedResLocale` This commit enables the `generateLocaleConfig` option in the `androidResources` block of `app/build.gradle`. It also adds a `resources.properties` file to `app/src/main/res/` to specify `en-US` as the `unqualifiedResLocale`. This configuration helps ensure correct locale handling for resources. --- app/build.gradle | 5 +++++ app/src/main/res/resources.properties | 1 + 2 files changed, 6 insertions(+) create mode 100644 app/src/main/res/resources.properties diff --git a/app/build.gradle b/app/build.gradle index 3bcb063..8e4680a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,11 @@ android { dataBinding false compose true } + + androidResources { + generateLocaleConfig true + } + splits { // Configures multiple APKs based on ABI. abi { diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..d5a3ddc --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US \ No newline at end of file From ac86aa0080db97737a81f31a0477f452126a7d02 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:28:17 +0800 Subject: [PATCH 38/48] Update string resources for clarity and localization This commit updates several string resources to improve clarity and provide Chinese translations for newly added strings. Key changes: - Renamed "Enable alert" to "Take a Break Reminder". - Renamed "Protect My Ears" to "Safe Volume Start". - Added `translatable="false"` to strings that should not be translated, such as `title_activity_main`, `icon_type`, `icon_type_description`, `hide_in_launcher`, and `title_activity_welcome`. - Updated the description for `show_icon_description` to mention potential system limitations in hiding the app icon. - Added new strings `enable_watching_detail` and `protection_detail` to provide more context for their respective features. - Added Chinese translations for new and existing strings including those related to notification settings, permissions, battery optimization, and feature details. --- app/src/main/res/values-zh-rCN/strings.xml | 19 +++++++++++++++++-- app/src/main/res/values/strings.xml | 18 ++++++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fe6c5a5..a0bc8d4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -31,14 +31,14 @@ 设备图标 %02d 分钟 %1$d 小时 %2$d 分钟 - 启用提醒 + 休息提醒 禁用提醒 当前连接 历史连接 已连接 设备连接状态指示 选择日期 - 保护耳朵 + 安全音量启动 不保护耳朵 保护了耳朵 保护耳朵 @@ -52,4 +52,19 @@ 睡眠定时器 用于睡眠定时器的通知 睡觉! + 通知设置 + 设置通知优先级 + 退出 + 完成 + 必要权限 + 可选 + 发送通知权限 + 本应用需要通知权限以在通知栏显示当前媒体音量。 + 禁用电池优化 + 禁用电池优化有助于降低应用被系统后台策略限制的可能。 + 显示应用图标 + 由于系统限制,某些设备上图标可能无法完全隐藏。 + 安全音量阈值 + 长时间使用耳机时会受到通知提醒。 + 连接耳机时,自动将音量调整到合适的范围。 \ 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 d208fa6..73cf850 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,14 +23,14 @@ Device icon %02d min %1$d h %2$d min - Enable alert + Take a Break Reminder Disable alert Current Connections Connections History Already connected Device connection state indicator Select a Date - Protect My Ears + Safe Volume Start Stop protecting ears Ears Protected Protecting ears @@ -51,13 +51,13 @@ Is that ASMR pleasing? Let\'s ROCK - MainActivity - Notification Icon Type - Choose notification icon type from percent and image. + MainActivity + Notification Icon Type + Choose notification icon type from percent and image. Notification Settings Configure notification importance Exit - Hide In Launcher + Hide In Launcher Finish Nessery Optional @@ -66,7 +66,9 @@ Disable Battery Optimization Turning off battery optimization for this app helps prevent the system from stopping it unexpectedly, reducing potential errors. Show App Icon in Launcher - Tapping the icon will take you to the settings page. - WelcomeActivity + Due to system limitations, the icon may not be completely hidden on some devices. + WelcomeActivity Safe Volume Threshold + Get notifications for prolonged headphone use. + Automatically adjusts headphone volume to a safe and comfortable level upon connection. \ No newline at end of file From 27861640be873d1d59557b2dc6644887aa406542 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:28:29 +0800 Subject: [PATCH 39/48] Update SettingsScreen UI text This commit updates the descriptive text for the "Enable Watching" and "Protection" switch rows in the `SettingsScreen`. Additionally, the color and padding of the settings group titles in `SettingsComponents.kt` have been adjusted. --- .../com/maary/liveinpeace/ui/screen/SettingsComponents.kt | 3 ++- .../java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 7c01178..16fac2a 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -173,7 +173,8 @@ fun ThresholdSlider(title: String, range: IntRange, onValueChangeFinished: (IntR text = title, style = Typography.titleMedium, modifier = Modifier - .padding(top = 8.dp, bottom = 8.dp) + .padding(bottom = 8.dp), + color = MaterialTheme.colorScheme.secondary ) // 内部状态的初始化逻辑保持不变 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 01b60d4..71fc5e4 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 @@ -194,7 +194,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( title = stringResource(R.string.enable_watching), - description = stringResource(R.string.enable_watching) /* todo */, + description = stringResource(R.string.enable_watching_detail), state = settingsViewModel.alertSwitchState.collectAsState().value, onCheckedChange = { settingsViewModel.alertSwitch() } ) @@ -204,7 +204,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { containerColor = MaterialTheme.colorScheme.secondaryContainer) { SwitchRow( title = stringResource(R.string.protection), - description = stringResource(R.string.protection) /* todo */, + description = stringResource(R.string.protection_detail), state = isProtectionOn, onCheckedChange = { settingsViewModel.protectionSwitch() } ) From 35bfc68f1711809f792f84e3e73ebe5854ed90ab Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:31:42 +0800 Subject: [PATCH 40/48] Add monochrome adaptive icon support This commit adds the `` element to the adaptive icon definitions (`ic_launcher.xml` and `ic_launcher_round.xml`). This allows the launcher icon to adapt to themed icon settings on supported Android versions, using `ic_launcher_foreground` as the drawable for the monochrome version. --- app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 1 + app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7353dbd..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7353dbd..ef49c99 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + \ No newline at end of file From 7d356b59c5466afa9e594a027dcaa3f34861e8cc Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:29:41 +0800 Subject: [PATCH 41/48] Update Gradle plugin versions This commit updates the following Gradle plugin versions: - Android Application and Library plugins to `8.9.3` - Kotlin Android plugin to `2.0.21` - KSP plugin to `2.0.21-1.0.26` - Kotlin Compose plugin to `2.0.21` - Dagger Hilt Android plugin to `2.56.2` --- build.gradle | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) mode change 100644 => 100755 build.gradle diff --git a/build.gradle b/build.gradle old mode 100644 new mode 100755 index cd72a90..23e565f --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,9 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.9.2' apply false - id 'com.android.library' version '8.9.2' apply false - id 'org.jetbrains.kotlin.android' version '2.0.0' apply false - id 'com.google.devtools.ksp' version "2.0.0-1.0.21" apply false - id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false - id 'com.google.dagger.hilt.android' version '2.56.1' apply false + id 'com.android.application' version '8.9.3' apply false + id 'com.android.library' version '8.9.3' apply false + id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'com.google.devtools.ksp' version "2.0.21-1.0.26" apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false + id 'com.google.dagger.hilt.android' version '2.56.2' apply false } \ No newline at end of file From 1f20fc90ea1439008e312f272a148cbbefc2ea8e Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Thu, 19 Jun 2025 23:28:12 +0800 Subject: [PATCH 42/48] Introduce build flavors for app icon visibility This commit introduces two product flavors, `iconEnabled` and `iconDisabled`, to control the initial visibility of the app icon in the launcher. - **Build Flavors**: - `iconEnabled`: Sets `mainActivityEnabled` to `true` in `AndroidManifest.xml` and defines `ICON_ENABLED` as `true` in `BuildConfig`. - `iconDisabled`: Sets `mainActivityEnabled` to `false` in `AndroidManifest.xml` and defines `ICON_ENABLED` as `false` in `BuildConfig`. - **PreferenceRepository**: - The default value for `PREF_VISIBLE_IN_LAUNCHER` in `isIconShown()` and `toggleIconVisibility()` now uses `BuildConfig.ICON_ENABLED`. - **APK Naming**: - Updated APK naming convention to include flavor name, ABI (if applicable), and version name (e.g., `LiveInPeace-iconEnabled-arm64-v8a-1.0.apk`, `LiveInPeace-iconDisabled-universal-1.0.apk`). - **ABI Configuration**: - Modified ABI splits to only include `arm64-v8a` and enable `universalApk`. - **Dependency Update**: - Updated `com.google.dagger:hilt-compiler` to version `2.56.2`. --- app/build.gradle | 64 ++++++++++++++----- app/src/main/AndroidManifest.xml | 2 +- .../database/PreferenceRepository.kt | 5 +- 3 files changed, 52 insertions(+), 19 deletions(-) mode change 100644 => 100755 app/build.gradle mode change 100644 => 100755 app/src/main/AndroidManifest.xml mode change 100644 => 100755 app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt diff --git a/app/build.gradle b/app/build.gradle old mode 100644 new mode 100755 index 8e4680a..b502d1b --- a/app/build.gradle +++ b/app/build.gradle @@ -8,6 +8,8 @@ plugins { id 'com.google.dagger.hilt.android' } +import com.android.build.api.variant.FilterConfiguration + def keystorePropertiesFile = rootProject.file("key.properties") def keystoreProperties = new Properties() keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) @@ -30,23 +32,39 @@ android { viewBinding true dataBinding false compose true + buildConfig true } androidResources { generateLocaleConfig true } + flavorDimensions "icon" + + productFlavors { + iconEnabled { + dimension "icon" + manifestPlaceholders = [mainActivityEnabled: "true"] + buildConfigField "boolean", "ICON_ENABLED", "true" + } + + iconDisabled { + dimension "icon" + manifestPlaceholders = [mainActivityEnabled: "false"] + buildConfigField "boolean", "ICON_ENABLED", "false" + } + } + splits { // Configures multiple APKs based on ABI. abi { // Enables building multiple APKs per ABI. enable true - // Specifies the ABIs that Gradle should create APKs for. - // This example creates APKs for the armeabi-v7a, arm64-v8a, x86, and x86_64 ABIs. - // Other ABIs that you could include are: mips, mips64, and ppc64. - universalApk false - include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + universalApk true + reset() + //noinspection ChromeOsAbiSupport + include "arm64-v8a" } } @@ -78,17 +96,31 @@ android { kotlinOptions { jvmTarget = '11' } - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { output -> - // 查找 ABI 过滤器 - def abiFilter = output.filters.find { it.filterType == "ABI" } - def abi = abiFilter?.identifier - - def apkName = "LiveInPeace-${abi}.apk" - if (abi == "x86_64") { - apkName = "LiveInPeace-x64.apk" + + androidComponents { + onVariants(selector().withBuildType("release")) { variant -> + variant.outputs.each { output -> + def abiFilter = output.filters.find { it.filterType == FilterConfiguration.FilterType.ABI } + def abi = abiFilter?.identifier + + def flavorName = variant.flavorName + def versionName = output.versionName.getOrNull() ?: "unknown" + def apkName = "" // 先声明一个空变量 + + if (abi != null) { + // ABI 包的处理逻辑 (if 分支) + // 这部分只对 arm64-v8a 包生效 + apkName = "LiveInPeace-${flavorName}-${abi}-${versionName}.apk" + if (abi == "x86_64") { // 虽然当前已移除,但保留逻辑无害 + apkName = "LiveInPeace-${flavorName}-x64-${versionName}.apk" + } + } else { + // 通用包 (Universal) 的处理逻辑 (else 分支) + // 当 abi 为 null 时,说明这是 universalApk + apkName = "LiveInPeace-${flavorName}-universal-${versionName}.apk" + } + output.outputFileName.set(apkName) } - output.outputFileName = apkName } } @@ -124,7 +156,7 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1' implementation "com.google.dagger:hilt-android:2.56.2" - ksp "com.google.dagger:hilt-compiler:2.56.1" + ksp "com.google.dagger:hilt-compiler:2.56.2" implementation "androidx.room:room-runtime:2.7.1" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml old mode 100644 new mode 100755 index 718e95e..392e54a --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ { return datastore.data.map { pref -> - pref[PREF_VISIBLE_IN_LAUNCHER] ?: false + pref[PREF_VISIBLE_IN_LAUNCHER] ?: BuildConfig.ICON_ENABLED } } suspend fun toggleIconVisibility() { datastore.edit { pref -> - val currentState = pref[PREF_VISIBLE_IN_LAUNCHER] ?: false + val currentState = pref[PREF_VISIBLE_IN_LAUNCHER] ?: BuildConfig.ICON_ENABLED pref[PREF_VISIBLE_IN_LAUNCHER] = !currentState } } From a91f16fa7f6b0e90ef841d71968321514af86054 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 19:26:21 +0800 Subject: [PATCH 43/48] Refactor CI and upload script This commit refactors the GitHub Actions workflow and the Telegram upload script: **GitHub Actions Workflow (`android.yml`):** - Updated artifact upload and download actions to `v4`. - Modified APK naming convention to include flavor (e.g., `iconEnabled`, `iconDisabled`) and ABI. - Updated artifact and release asset names to reflect the new APK naming. - Changed `asset_content_type` for release assets from `application/zip` to `application/vnd.android.package-archive`. - Adjusted environment variable names for APK paths to align with the new naming scheme. - Simplified some run commands by removing redundant `ls` calls. **Telegram Upload Script (`upload.py`):** - Refactored script to send APKs as a `MediaGroup` instead of individual documents. - Implemented `sendMediaGroup` function to handle sending multiple files. - Implemented `sendTextMessage` for sending formatted release messages. - The script now expects two APK paths (`APK_FILE_UPLOAD1`, `APK_FILE_UPLOAD2`) from environment variables. - The notification message now includes version name and commit message, retrieved from environment variables. - Removed unused `findString` and `genFileDirectory` functions. - The main execution block now constructs a formatted message with version and commit details and sends the APKs and the message to a predefined Telegram chat. --- .github/scripts/upload.py | 99 +++++++++++++++++------------ .github/workflows/android.yml | 114 ++++++++++++++++++---------------- 2 files changed, 120 insertions(+), 93 deletions(-) diff --git a/.github/scripts/upload.py b/.github/scripts/upload.py index e5fc3ef..275253a 100644 --- a/.github/scripts/upload.py +++ b/.github/scripts/upload.py @@ -6,47 +6,70 @@ urlPrefix = apiAddress + "bot" + os.getenv("TELEGRAM_TOKEN") -def findString(sourceStr, targetStr): - if str(sourceStr).find(str(targetStr)) == -1: - return False - else: - return True +def sendMediaGroup(user_id, paths): + url = urlPrefix + "/sendMediaGroup" + files = {} + media = [] + for i, path in enumerate(paths): + file_key = f'file{i}' + files[file_key] = open(path, 'rb') + media_item = { + 'type': 'document', + 'media': f'attach://{file_key}' + } + media.append(media_item) -def genFileDirectory(path): - files_walk = os.walk(path) - target = { + data = { + 'chat_id': user_id, + 'media': json.dumps(media) } - for root, dirs, file_name_dic in files_walk: - for fileName in file_name_dic: - if findString(fileName, "v8a"): - target["arm64"] = (fileName, open(path + "/" + fileName, "rb")) - if findString(fileName, "v7a"): - target["armeabi"] = (fileName, open(path + "/" + fileName, "rb")) - if findString(fileName, "x86.apk"): - target["i386"] = (fileName, open(path + "/" + fileName, "rb")) - if findString(fileName, "x86_64"): - target["amd64"] = (fileName, open(path + "/" + fileName, "rb")) - - return target - - -def sendDocument(user_id, path, message = "", entities = None): - files = {'document': open(path, 'rb')} - data = {'chat_id': user_id, - 'caption': message, - 'parse_mode': 'Markdown', - 'caption_entities': entities} - response = requests.post(urlPrefix + "/sendDocument", files=files, data=data) - print(response.json()) - - -def sendAPKs(path): - apks = os.listdir("apks") - apks.sort() - apk = os.path.join("apks", apks[0]) - sendDocument(user_id="@maaryIsTyping", path = apk, message="#app #apk https://github.com/Steve-Mr/LiveInPeace") + + response = requests.post(url, files=files, data=data) + print("MediaGroup Response:", response.json()) + + for f in files.values(): + f.close() + + +def sendTextMessage(user_id, message, disable_preview=False): + url = urlPrefix + "/sendMessage" + + data = { + 'chat_id': user_id, + 'text': message, + 'parse_mode': 'Markdown', + 'disable_web_page_preview': disable_preview + } + response = requests.post(url, data=data) + + response_data = response.json() + + if __name__ == '__main__': - sendAPKs("./apks") + # 从环境变量中获取两个 APK 文件的路径 + apk_path1 = os.getenv("APK_FILE_UPLOAD1") + apk_path2 = os.getenv("APK_FILE_UPLOAD2") + + # 检查路径是否存在 + if not apk_path1 or not apk_path2: + print("错误:未能在环境变量中找到 APK 文件路径。") + exit(1) + + # 将两个 APK 路径放入一个列表 + apk_paths = [apk_path1, apk_path2] + + # 从环境变量中获取版本信息和提交信息来构建消息内容 + version_name = os.getenv("VERSION_NAME", "N/A") + commit_message = os.getenv("COMMIT_MESSAGE", "无提交信息。") + + message = ( + f"#app #apk\n" + f"**版本:** `{version_name}`\n\n" + f"**更新内容:**\n{commit_message}\n\n" + f"https://github.com/Steve-Mr/LiveInPeace" + ) + sendMediaGroup(user_id="@maaryIsTyping", paths=apk_paths) + sendTextMessage(user_id="@maaryIsTyping", message=message, disable_preview=True) \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f074180..e11c855 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -6,9 +6,7 @@ on: jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 - name: set up JDK 17 @@ -19,53 +17,48 @@ jobs: cache: gradle - name: Storing key.properties - run: | - echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > ./key.properties - ls ./ - ls -l key.properties + run: echo "${{ secrets.KEY_PROPERTIES }}" | base64 --decode > ./key.properties - name: Storing keystore - run: | - echo "${{ secrets.KEYSTORE }}" | base64 --decode > ./app/key.keystore - ls ./app - ls -l ./app/key.keystore + run: echo "${{ secrets.KEYSTORE }}" | base64 --decode > ./app/key.keystore - name: Storing keystore - run: | - echo "${{ secrets.KEYSTORE }}" | base64 --decode > ./key.keystore - ls -l ./key.keystore + run: echo "${{ secrets.KEYSTORE }}" | base64 --decode > ./key.keystore - name: Grant execute permission for gradlew run: chmod +x gradlew + + # Build with Gradle 命令不变,它会自动构建所有 flavors 和 ABIs - name: Build with Gradle run: | ./gradlew :app:assembleRelease - echo "APK_FILE=$(find app/build/outputs/apk -name '*arm64*.apk')" >> $GITHUB_ENV - echo "APK_FILE_ARMV7=$(find app/build/outputs/apk -name '*v7a*.apk')" >> $GITHUB_ENV - echo "APK_FILE_X86=$(find app/build/outputs/apk -name '*x86*.apk')" >> $GITHUB_ENV - echo "APK_FILE_X64=$(find app/build/outputs/apk -name '*x64*.apk')" >> $GITHUB_ENV + echo "APK_FILE_ARMV8_ICON_ENABLED=$(find app/build/outputs/apk -name '*iconEnabled-arm64*.apk')" >> $GITHUB_ENV + echo "APK_FILE_UNI_ICON_ENABLED=$(find app/build/outputs/apk -name '*iconEnabled-universal*.apk')" >> $GITHUB_ENV + echo "APK_FILE_ARMV8_ICON_DISABLED=$(find app/build/outputs/apk -name '*iconDisabled-arm64*.apk')" >> $GITHUB_ENV + echo "APK_FILE_UNI_ICON_DISABLED=$(find app/build/outputs/apk -name '*iconDisabled-universal*.apk')" >> $GITHUB_ENV - - uses: actions/upload-artifact@v2 - name: Upload apk (arm64-v8a) + - uses: actions/upload-artifact@v4 + name: Upload apk (icon-enabled-arm64-v8a) with: - name: LiveInPeace-arm64-v8a - path: ${{ env.APK_FILE }} - - uses: actions/upload-artifact@v2 - name: Upload apk (armeabi-v7a) + name: LiveInPeace-icon-enabled-arm64-v8a.apk + path: ${{ env.APK_FILE_ARMV8_ICON_ENABLED }} + - uses: actions/upload-artifact@v4 + name: Upload apk (icon-enabled-universal) with: - name: LiveInPeace-armeabi-v7a - path: ${{ env.APK_FILE_ARMV7 }} - - uses: actions/upload-artifact@v2 - name: Upload apk (x86_64) + name: LiveInPeace-icon-enabled-universal.apk + path: ${{ env.APK_FILE_UNI_ICON_ENABLED }} + - uses: actions/upload-artifact@v4 + name: Upload apk (icon-disabled-arm64-v8a) with: - name: LiveInPeace-x86_64 - path: ${{ env.APK_FILE_X64 }} - - uses: actions/upload-artifact@v2 - name: Upload apk (x86) + name: LiveInPeace-icon-disabled-arm64-v8a.apk + path: ${{ env.APK_FILE_ARMV8_ICON_DISABLED }} + - uses: actions/upload-artifact@v4 + name: Upload apk (icon-disabled-universal) with: - name: LiveInPeace-x86 - path: ${{ env.APK_FILE_X86 }} + name: LiveInPeace-icon-disabled-universal.apk + path: ${{ env.APK_FILE_UNI_ICON_DISABLED }} + - name: Create Release id: create_release @@ -79,47 +72,48 @@ jobs: body: | ## Changes ${{ github.event.pull_request.body }} - ${{ steps.show_pr_commits.outputs.commits }} + # --- 修改开始:更新上传到 Release 的逻辑 --- - uses: actions/upload-release-asset@v1 - name: Upload apk (arm64-v8a) + name: Upload Release APK (iconEnabled, arm64-v8a) env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_name: LiveInPeace-arm64-v8a.apk - asset_path: ${{ env.APK_FILE }} - asset_content_type: application/zip + asset_path: ${{ env.APK_FILE_ARMV8_ICON_ENABLED }} + asset_name: LiveInPeace-icon-enabled-arm64-v8a.apk + asset_content_type: application/vnd.android.package-archive - uses: actions/upload-release-asset@v1 - name: Upload apk (armeabi-v7a) + name: Upload Release APK (iconEnabled, universal) env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_name: LiveInPeace-armeabi-v7a.apk - asset_path: ${{ env.APK_FILE_ARMV7 }} - asset_content_type: application/zip + asset_path: ${{ env.APK_FILE_UNI_ICON_ENABLED }} + asset_name: LiveInPeace-icon-enabled-universal.apk + asset_content_type: application/vnd.android.package-archive - uses: actions/upload-release-asset@v1 - name: Upload apk (x86_64) + name: Upload Release APK (iconDisabled, arm64-v8a) env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_name: LiveInPeace-x86_64.apk - asset_path: ${{ env.APK_FILE_X64 }} - asset_content_type: application/zip + asset_path: ${{ env.APK_FILE_ARMV8_ICON_DISABLED }} + asset_name: LiveInPeace-icon-disabled-arm64-v8a.apk + asset_content_type: application/vnd.android.package-archive - uses: actions/upload-release-asset@v1 - name: Upload apk (x86) + name: Upload Release APK (iconDisabled, universal) env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_name: LiveInPeace-x86.apk - asset_path: ${{ env.APK_FILE_X86 }} - asset_content_type: application/zip + asset_path: ${{ env.APK_FILE_UNI_ICON_DISABLED }} + asset_name: LiveInPeace-icon-disabled-universal.apk + asset_content_type: application/vnd.android.package-archive + # --- 修改结束 --- upload: name: Upload Release @@ -129,11 +123,12 @@ jobs: - telegram-bot-api steps: - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 # 建议使用 v3 with: path: artifacts + - name: Download Telegram Bot API Binary - uses: actions/download-artifact@master + uses: actions/download-artifact@v4 with: name: telegram-bot-api-binary path: . @@ -142,14 +137,21 @@ jobs: run: | mkdir apks find artifacts -name "*.apk" -exec cp {} apks \; - echo "APK_FILE_UPLOAD=$(find apks -name '*arm64*.apk')" >> $GITHUB_ENV + + # 添加一个调试步骤,列出所有复制过来的APK,方便排查 + echo "--- Listing files in apks directory ---" ls ./apks + + # 修正这里的匹配模式,确保与 build job 一致 + echo "APK_FILE_UPLOAD1=$(find apks -name '*iconEnabled-arm64*.apk')" >> $GITHUB_ENV + echo "APK_FILE_UPLOAD2=$(find apks -name '*iconDisabled-arm64*.apk')" >> $GITHUB_ENV + - name: Get Apk Info id: apk uses: JantHsueh/get-apk-info-action@master with: - apkPath: ${{ env.APK_FILE_UPLOAD }} + apkPath: ${{ env.APK_FILE_UPLOAD1 }} - name: Release run: | @@ -163,6 +165,8 @@ jobs: VERSION_CODE: ${{steps.apk.outputs.versionCode}} VERSION_NAME: ${{steps.apk.outputs.versionNum}} COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + APK_FILE_UPLOAD1: ${{ env.APK_FILE_UPLOAD1 }} + APK_FILE_UPLOAD2: ${{ env.APK_FILE_UPLOAD2 }} telegram-bot-api: name: Telegram Bot API @@ -176,7 +180,7 @@ jobs: git status telegram-bot-api >> telegram-bot-api-status - name: Cache Bot API Binary id: cache-bot-api - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: telegram-bot-api-binary key: CI-telegram-bot-api-${{ hashFiles('telegram-bot-api-status') }} From c86124e1fd2a90f9b896586191234a61d47a0c6e Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 19:41:30 +0800 Subject: [PATCH 44/48] Remove SettingsReceiver and related constants This commit removes the `SettingsReceiver` class and its associated constants. The receiver was previously used for handling settings changes via notifications, but this functionality is no longer needed. - Deleted `SettingsReceiver.kt`. - Removed `SettingsReceiver` declaration from `AndroidManifest.xml`. - Deleted the following constants from `Constants.kt`: - `ACTION_CANCEL` - `ACTION_NAME_SET_IMG` - `ACTION_NAME_SET_NUM` - `ACTION_ENABLE_WATCHING` - `ACTION_DISABLE_WATCHING` - `ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT` - `ACTION_NAME_SETTINGS` --- app/src/main/AndroidManifest.xml | 7 - .../java/com/maary/liveinpeace/Constants.kt | 14 - .../liveinpeace/receiver/SettingsReceiver.kt | 247 ------------------ 3 files changed, 268 deletions(-) delete mode 100644 app/src/main/java/com/maary/liveinpeace/receiver/SettingsReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 392e54a..5984ce6 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -87,13 +87,6 @@ - - - - - diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index 3cc6012..e24baa6 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -2,8 +2,6 @@ package com.maary.liveinpeace class Constants { companion object { - // Cancel 的 Action - const val ACTION_CANCEL = "com.maary.liveinpeace.receiver.SettingsReceiver.Cancel" // 使用字符式图标 const val MODE_NUM = 0 // 使用图像式图标 @@ -32,18 +30,6 @@ class Constants { const val ID_NOTIFICATION_PROTECT = 4 const val ID_NOTIFICATION_WELCOME = 0 const val ID_NOTIFICATION_SLEEPTIMER = 5 - // 设置图像式图标 Action - const val ACTION_NAME_SET_IMG = "com.maary.liveinpeace.receiver.SettingsReceiver.SetIconImg" - // 设置字符式图标 Action - const val ACTION_NAME_SET_NUM = "com.maary.liveinpeace.receiver.SettingsReceiver.SetIconNum" - // 启用长时间连接提醒 Action - const val ACTION_ENABLE_WATCHING = "com.maary.liveinpeace.receiver.SettingsReceiver.EnableWatching" - // 禁用长时间连接提醒 Action - const val ACTION_DISABLE_WATCHING = "com.maary.liveinpeace.receiver.SettingsReceiver.DisableWatching" - // toggle 设备连接调整音量 Action - const val ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT = "com.maary.liveinpeace.receiver.SettingsReceiver.ToggleAdjustment" - // 设置 Action - const val ACTION_NAME_SETTINGS = "com.maary.liveinpeace.receiver.SettingsReceiver" // 静音广播名称 const val BROADCAST_ACTION_MUTE = "com.maary.liveinpeace.MUTE_MEDIA" const val BROADCAST_ACTION_SLEEPTIMER_CANCEL = "com.maary.liveinpeace.action.CANCEL" diff --git a/app/src/main/java/com/maary/liveinpeace/receiver/SettingsReceiver.kt b/app/src/main/java/com/maary/liveinpeace/receiver/SettingsReceiver.kt deleted file mode 100644 index 8042468..0000000 --- a/app/src/main/java/com/maary/liveinpeace/receiver/SettingsReceiver.kt +++ /dev/null @@ -1,247 +0,0 @@ -package com.maary.liveinpeace.receiver - -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.core.app.NotificationCompat -import com.maary.liveinpeace.Constants.Companion.ACTION_CANCEL -import com.maary.liveinpeace.Constants.Companion.ACTION_DISABLE_WATCHING -import com.maary.liveinpeace.Constants.Companion.ACTION_ENABLE_WATCHING -import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SET_IMG -import com.maary.liveinpeace.Constants.Companion.ACTION_NAME_SET_NUM -import com.maary.liveinpeace.Constants.Companion.ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_SETTINGS -import com.maary.liveinpeace.Constants.Companion.MODE_IMG -import com.maary.liveinpeace.Constants.Companion.MODE_NUM -import com.maary.liveinpeace.Constants.Companion.PREF_ENABLE_EAR_PROTECTION -import com.maary.liveinpeace.Constants.Companion.PREF_ICON -import com.maary.liveinpeace.Constants.Companion.PREF_WATCHING_CONNECTING_TIME -import com.maary.liveinpeace.Constants.Companion.SHARED_PREF -import com.maary.liveinpeace.R -import com.maary.liveinpeace.service.ForegroundService - -class SettingsReceiver: BroadcastReceiver() { - override fun onReceive(p0: Context?, p1: Intent?) { - if (ACTION_NAME_SETTINGS == p1?.action){ - - val sharedPref = p0?.getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) - - val actionImgIcon = p0?.let { - generateAction( - it, - SettingsReceiver::class.java, - ACTION_NAME_SET_IMG, - R.string.icon_type_img - ) - } - - val actionNumIcon = p0?.let { - generateAction( - it, - SettingsReceiver::class.java, - ACTION_NAME_SET_NUM, - R.string.icon_type_num - ) - } - - val actionCancel = p0?.let { - generateAction( - it, - SettingsReceiver::class.java, - ACTION_CANCEL, - R.string.cancel - ) - } - - val actionEnableWatching = p0?.let { - generateAction( - it, - SettingsReceiver::class.java, - ACTION_ENABLE_WATCHING, - R.string.enable_watching - ) - } - - val actionDisableWatching = p0?.let { - generateAction( - it, - SettingsReceiver::class.java, - ACTION_DISABLE_WATCHING, - R.string.disable_watching - ) - } - - val actions: MutableList = ArrayList() - if (sharedPref!!.getInt(PREF_ICON, MODE_IMG) == MODE_NUM){ - actionImgIcon?.let { actions.add(it) } - }else { - actionNumIcon?.let { actions.add(it) } - } - if (sharedPref.getBoolean(PREF_WATCHING_CONNECTING_TIME, false)){ - actionDisableWatching?.let { actions.add(it) } - }else { - actionEnableWatching?.let { actions.add(it) } - } - actionCancel?.let { actions.add(it) } - - notify(p0, actions) - } - - if (ACTION_NAME_SET_IMG == p1?.action){ - val sharedPreferences = p0?.getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) - if (sharedPreferences != null) { - with(sharedPreferences.edit()){ - putInt(PREF_ICON, MODE_IMG) - apply() - val notificationManager: NotificationManager = - p0.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val foregroundServiceIntent = Intent(p0, ForegroundService::class.java) - p0.stopService(foregroundServiceIntent) - p0.startForegroundService(foregroundServiceIntent) - notificationManager.cancel(ID_NOTIFICATION_SETTINGS) - } - } - } - - if (ACTION_NAME_SET_NUM == p1?.action){ - val sharedPreferences = p0?.getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) - if (sharedPreferences != null) { - with(sharedPreferences.edit()){ - putInt(PREF_ICON, MODE_NUM) - apply() - val notificationManager: NotificationManager = - p0.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val foregroundServiceIntent = Intent(p0, ForegroundService::class.java) - p0.stopService(foregroundServiceIntent) - p0.startForegroundService(foregroundServiceIntent) - notificationManager.cancel(ID_NOTIFICATION_SETTINGS) - } - } - } - - if (ACTION_ENABLE_WATCHING == p1?.action){ - val sharedPreferences = p0?.getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) - if (sharedPreferences != null) { - with(sharedPreferences.edit()){ - putBoolean(PREF_WATCHING_CONNECTING_TIME, true) - apply() - val notificationManager: NotificationManager = - p0.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val foregroundServiceIntent = Intent(p0, ForegroundService::class.java) - p0.stopService(foregroundServiceIntent) - p0.startForegroundService(foregroundServiceIntent) - notificationManager.cancel(ID_NOTIFICATION_SETTINGS) - } - } - } - - if (ACTION_DISABLE_WATCHING == p1?.action){ - val sharedPreferences = p0?.getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) - if (sharedPreferences != null) { - with(sharedPreferences.edit()){ - putBoolean(PREF_WATCHING_CONNECTING_TIME, false) - apply() - val notificationManager: NotificationManager = - p0.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val foregroundServiceIntent = Intent(p0, ForegroundService::class.java) - p0.stopService(foregroundServiceIntent) - p0.startForegroundService(foregroundServiceIntent) - notificationManager.cancel(ID_NOTIFICATION_SETTINGS) - } - } - } - - if (ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT == p1?.action){ - val sharedPreferences = p0?.getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) - if (sharedPreferences != null) { - with(sharedPreferences.edit()){ - putBoolean(PREF_ENABLE_EAR_PROTECTION, - !sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false) - ) - apply() - val foregroundServiceIntent = Intent(p0, ForegroundService::class.java) - p0.stopService(foregroundServiceIntent) - p0.startForegroundService(foregroundServiceIntent) - } - } - } - - if (ACTION_CANCEL == p1?.action){ - val notificationManager: NotificationManager = - p0?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_SETTINGS) - } - - } - - - private fun generateAction( - context: Context, - targetClass: Class<*>, - actionName: String, - actionText: Int - - ): NotificationCompat.Action { - val intent = Intent(context, targetClass).apply { - action = actionName - } - - val pendingIntent: PendingIntent = - PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - - return NotificationCompat.Action.Builder( - R.drawable.ic_baseline_settings_24, - context.getString(actionText), - pendingIntent - ).build() - } - - private fun notify( - context: Context, - actions: List - ) { - - val notificationSettings = context.let { - NotificationCompat.Builder( - it, - CHANNEL_ID_SETTINGS - ) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_baseline_settings_24) - .setShowWhen(false) - .setContentTitle(context.resources?.getString(R.string.LIP_settings)) - .setOnlyAlertOnce(true) - .setGroupSummary(false) - .setGroup(ID_NOTIFICATION_GROUP_SETTINGS) - } - - for (action in actions) { - notificationSettings.addAction(action) - } - - val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.notify(ID_NOTIFICATION_SETTINGS, notificationSettings.build()) - } -} \ No newline at end of file From 533d16d87f31f2dc88609c23a5f08828cbb7488a Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 19:50:02 +0800 Subject: [PATCH 45/48] Refactor: Remove unused code and imports This commit removes unused constants, imports, and code sections to improve maintainability and reduce clutter. Specifically, the following were removed: - Unused constants in `Constants.kt` related to icon modes, notification text size, and specific notification IDs. - Unused imports across several files including `ConnectionDao.kt`, `ConnectionRoomDatabase.kt`, `BootCompleteReceiver.kt`, `ForegroundService.kt`, `QSTileService.kt`, `ConnectionListAdapter.kt`, and various UI screens. - The unused `DropdownItem` and `DropdownRow` composables in `SettingsComponents.kt`. - Unused `java.sql.Date` import in `ConnectionDao.kt`. - Public modifier from `ConnectionRoomDatabase`. - Unused `Context.notificationManager()` extension function in `SleepNotification.kt` was made private. - Removed unused `VOLUME_ADJUST_ATTEMPTS` constant in `ForegroundService.kt`. - Cleaned up comments and TODOs in `HistoryActivity.kt`. - Simplified the `LiveInPeaceTheme` by removing an unnecessary `Build.VERSION.SDK_INT` check for dynamic colors. --- .../liveinpeace/ConnectionListAdapter.kt | 3 - .../java/com/maary/liveinpeace/Constants.kt | 14 --- .../maary/liveinpeace/SleepNotification.kt | 2 +- .../liveinpeace/activity/HistoryActivity.kt | 3 + .../liveinpeace/activity/WelcomeActivity.kt | 7 -- .../liveinpeace/database/ConnectionDao.kt | 1 - .../database/ConnectionRoomDatabase.kt | 8 +- .../receiver/BootCompleteReceiver.kt | 1 - .../liveinpeace/service/ForegroundService.kt | 16 ++-- .../liveinpeace/service/QSTileService.kt | 13 --- .../ui/screen/SettingsComponents.kt | 85 ------------------- .../liveinpeace/ui/screen/SettingsScreen.kt | 31 +------ .../liveinpeace/ui/screen/WelcomeScreen.kt | 13 +-- .../com/maary/liveinpeace/ui/theme/Theme.kt | 4 +- 14 files changed, 22 insertions(+), 179 deletions(-) diff --git a/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt b/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt index b512a0c..73a4633 100644 --- a/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt +++ b/app/src/main/java/com/maary/liveinpeace/ConnectionListAdapter.kt @@ -4,19 +4,16 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.media.AudioDeviceInfo -import android.media.Image import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView -import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.maary.liveinpeace.database.Connection import java.util.concurrent.TimeUnit -import kotlin.coroutines.coroutineContext class ConnectionListAdapter : ListAdapter(ConnectionsComparator()){ diff --git a/app/src/main/java/com/maary/liveinpeace/Constants.kt b/app/src/main/java/com/maary/liveinpeace/Constants.kt index e24baa6..d68b973 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -2,15 +2,8 @@ package com.maary.liveinpeace class Constants { companion object { - // 使用字符式图标 - const val MODE_NUM = 0 - // 使用图像式图标 - const val MODE_IMG = 1 // SharedPref 名称 const val SHARED_PREF = "com.maary.liveinpeace.pref" - // 图标类型的 SharedPref 项目名称 - const val PREF_ICON = "icon_type" - const val PREF_NOTIFY_TEXT_SIZE = "notification_text_size" const val PREF_WATCHING_CONNECTING_TIME = "watching_connecting" const val PREF_ENABLE_EAR_PROTECTION = "ear_protection_enabled" const val PREF_WELCOME_FINISHED = "welcome_finished" @@ -19,16 +12,12 @@ 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_EAR_PROTECTION_THRESHOLD = "ear_protection_threshold" const val EAR_PROTECTION_LOWER_THRESHOLD = 10 const val EAR_PROTECTION_UPPER_THRESHOLD = 25 - // 设置通知 id - const val ID_NOTIFICATION_SETTINGS = 3 // 前台通知 id const val ID_NOTIFICATION_FOREGROUND = 1 const val ID_NOTIFICATION_ALERT = 2 const val ID_NOTIFICATION_PROTECT = 4 - const val ID_NOTIFICATION_WELCOME = 0 const val ID_NOTIFICATION_SLEEPTIMER = 5 // 静音广播名称 const val BROADCAST_ACTION_MUTE = "com.maary.liveinpeace.MUTE_MEDIA" @@ -45,8 +34,6 @@ class Constants { const val BROADCAST_ACTION_CONNECTIONS_UPDATE = "com.maary.liveinpeace.CONNECTIONS_UPDATE" const val EXTRA_CONNECTIONS_LIST = "com.maary.liveinpeace.extra.CONNECTIONS_LIST" - // 当音量操作动作太过频繁后等待时间 - const val REQUESTING_WAIT_MILLIS = 500 // 不同通知频道 ID const val CHANNEL_ID_DEFAULT = "LIP_FOREGROUND" const val CHANNEL_ID_SETTINGS = "LIP_SETTINGS" @@ -60,7 +47,6 @@ class Constants { const val DEBOUNCE_TIME_MS = 500 // 不同通知的 GROUP ID const val ID_NOTIFICATION_GROUP_FORE = "LIP_notification_group_foreground" - const val ID_NOTIFICATION_GROUP_SETTINGS = "LIP_notification_group_settings" const val ID_NOTIFICATION_GROUP_ALERTS = "LIP_notification_group_alerts" const val ID_NOTIFICATION_GROUP_PROTECT = "LIP_notification_group_protect" const val ID_NOTIFICATION_GROUP_SLEEPTIMER = "LIP_notification_group_sleeptimer" diff --git a/app/src/main/java/com/maary/liveinpeace/SleepNotification.kt b/app/src/main/java/com/maary/liveinpeace/SleepNotification.kt index 7b001d2..f426837 100644 --- a/app/src/main/java/com/maary/liveinpeace/SleepNotification.kt +++ b/app/src/main/java/com/maary/liveinpeace/SleepNotification.kt @@ -59,7 +59,7 @@ object SleepNotification { abstract fun title(context: Context): CharSequence? } - fun Context.notificationManager(): NotificationManager? = getSystemService(NotificationManager::class.java) + private fun Context.notificationManager(): NotificationManager? = getSystemService(NotificationManager::class.java) fun Context.find() = notificationManager()?.activeNotifications?.firstOrNull { it.id == ID_NOTIFICATION_SLEEPTIMER }?.notification diff --git a/app/src/main/java/com/maary/liveinpeace/activity/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/activity/HistoryActivity.kt index 425d5be..4ff8aee 100644 --- a/app/src/main/java/com/maary/liveinpeace/activity/HistoryActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/activity/HistoryActivity.kt @@ -37,6 +37,9 @@ import java.util.Locale // Remove DeviceMapChangeListener from the class declaration class HistoryActivity : AppCompatActivity() { + //todo add swipt to change date + //todo show current connections in a different color + //todo show connections start time and end time in the list private lateinit var binding: ActivityHistoryBinding private val connectionViewModel: ConnectionViewModel by viewModels { ConnectionViewModelFactory((application as LiveInPeaceApplication).repository) diff --git a/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt b/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt index aab7924..a3ab611 100644 --- a/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt +++ b/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt @@ -6,13 +6,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import com.maary.liveinpeace.ui.screen.WelcomeScreen import com.maary.liveinpeace.ui.theme.LiveInPeaceTheme import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/maary/liveinpeace/database/ConnectionDao.kt b/app/src/main/java/com/maary/liveinpeace/database/ConnectionDao.kt index d7a55bf..b2518b8 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/ConnectionDao.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/ConnectionDao.kt @@ -5,7 +5,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow -import java.sql.Date @Dao interface ConnectionDao { diff --git a/app/src/main/java/com/maary/liveinpeace/database/ConnectionRoomDatabase.kt b/app/src/main/java/com/maary/liveinpeace/database/ConnectionRoomDatabase.kt index b29cea1..b57e25b 100644 --- a/app/src/main/java/com/maary/liveinpeace/database/ConnectionRoomDatabase.kt +++ b/app/src/main/java/com/maary/liveinpeace/database/ConnectionRoomDatabase.kt @@ -4,15 +4,9 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import androidx.sqlite.db.SupportSQLiteDatabase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import java.sql.Date @Database(entities = [Connection::class], version = 1, exportSchema = false) -public abstract class ConnectionRoomDatabase : RoomDatabase() { +abstract class ConnectionRoomDatabase : RoomDatabase() { abstract fun connectionDao(): ConnectionDao diff --git a/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt b/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt index 82c6c73..72bb03f 100644 --- a/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt +++ b/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt @@ -7,7 +7,6 @@ import android.util.Log import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import com.maary.liveinpeace.BootWorker -import com.maary.liveinpeace.service.ForegroundService class BootCompleteReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { 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 db07e52..78fdb54 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/ForegroundService.kt @@ -21,12 +21,10 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat import com.maary.liveinpeace.Constants -import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_LOWER_THRESHOLD -import com.maary.liveinpeace.Constants.Companion.EAR_PROTECTION_UPPER_THRESHOLD import com.maary.liveinpeace.DeviceTimer -import com.maary.liveinpeace.activity.MainActivity import com.maary.liveinpeace.R import com.maary.liveinpeace.SleepNotification.find +import com.maary.liveinpeace.activity.MainActivity import com.maary.liveinpeace.database.Connection import com.maary.liveinpeace.database.ConnectionDao import com.maary.liveinpeace.database.ConnectionRoomDatabase @@ -36,13 +34,20 @@ import com.maary.liveinpeace.receiver.SleepReceiver import com.maary.liveinpeace.receiver.VolumeReceiver import dagger.hilt.android.AndroidEntryPoint import jakarta.inject.Inject -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.text.DateFormat import java.time.LocalDate -import java.util.* +import java.util.Date import java.util.concurrent.ConcurrentHashMap /** @@ -226,7 +231,6 @@ class ForegroundService : Service() { */ private val audioDeviceCallback = object : AudioDeviceCallback() { private val CALLBACK_TAG = "AudioDeviceCallback" - private val VOLUME_ADJUST_ATTEMPTS = 100 private val IGNORED_DEVICE_TYPES = setOf( AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index a7ad640..a71acb8 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -1,31 +1,18 @@ package com.maary.liveinpeace.service -import android.Manifest import android.annotation.SuppressLint -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.graphics.drawable.Icon -import android.net.Uri import android.os.Build -import android.os.PowerManager -import android.provider.Settings import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import androidx.annotation.RequiresApi -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat import com.maary.liveinpeace.Constants -import com.maary.liveinpeace.Constants.Companion.CHANNEL_ID_WELCOME -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_GROUP_SETTINGS -import com.maary.liveinpeace.Constants.Companion.ID_NOTIFICATION_WELCOME -import com.maary.liveinpeace.Constants.Companion.REQUESTING_WAIT_MILLIS import com.maary.liveinpeace.R import com.maary.liveinpeace.activity.WelcomeActivity import com.maary.liveinpeace.database.PreferenceRepository diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt index 16fac2a..5e737a7 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -1,11 +1,7 @@ package com.maary.liveinpeace.ui.screen -import android.transition.Slide -import android.util.Range -import androidx.annotation.FloatRange import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,18 +10,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RangeSlider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface @@ -41,16 +29,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.util.toRange import com.maary.liveinpeace.ui.theme.Typography -import com.maary.liveinpeace.R @Composable fun TextContent(modifier: Modifier = Modifier, title: String, description: String? = null, color: Color = MaterialTheme.colorScheme.secondary) { @@ -71,53 +53,6 @@ fun TextContent(modifier: Modifier = Modifier, title: String, description: Strin } } - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DropdownItem(modifier: Modifier, options: MutableList, position: Int, onItemClicked: (Int) -> Unit) { - var expanded by remember { - mutableStateOf(false) - } - - Box(modifier = modifier) { - ExposedDropdownMenuBox( - modifier = - Modifier.padding(8.dp), - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - OutlinedTextField( - modifier = Modifier - .wrapContentWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - value = options[position],//text, - onValueChange = {}, - readOnly = true, - singleLine = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - ) - ExposedDropdownMenu( - modifier = Modifier.wrapContentWidth(), - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - options.forEach { option -> - DropdownMenuItem( - modifier = Modifier.wrapContentWidth(), - text = { Text(option, style = Typography.bodyLarge) }, - onClick = { - expanded = false - onItemClicked(options.indexOf(option)) - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, - ) - } - } - } - } -} - @Composable fun SwitchRow( title: String, @@ -140,26 +75,6 @@ fun SwitchRow( } } -@Composable -fun DropdownRow(options: MutableList, position: Int, onItemClicked: (Int) -> Unit) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextContent( - modifier = Modifier.weight(3f), - title = stringResource(id = R.string.icon_type), - description = stringResource(id = R.string.icon_type_description) - ) - DropdownItem(modifier = Modifier.weight(2f), options = options, - position = position, onItemClicked = onItemClicked) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ThresholdSlider(title: String, range: IntRange, onValueChangeFinished: (IntRange) -> Unit) { 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 71fc5e4..a9d2a5b 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 @@ -5,73 +5,48 @@ import android.app.Activity import android.content.Intent import android.os.Build import android.provider.Settings -import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.maary.liveinpeace.ui.theme.Typography -import com.maary.liveinpeace.R -import com.maary.liveinpeace.viewmodel.SettingsViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberPermissionState +import com.maary.liveinpeace.R import com.maary.liveinpeace.activity.HistoryActivity +import com.maary.liveinpeace.ui.theme.Typography +import com.maary.liveinpeace.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt index 111a8fd..a56b718 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -3,7 +3,6 @@ package com.maary.liveinpeace.ui.screen import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult @@ -11,7 +10,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -19,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button @@ -37,25 +34,21 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.maary.liveinpeace.R -import com.maary.liveinpeace.viewmodel.WelcomeViewModel import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.maary.liveinpeace.R import com.maary.liveinpeace.activity.MainActivity -import com.maary.liveinpeace.ui.theme.Typography +import com.maary.liveinpeace.viewmodel.WelcomeViewModel @SuppressLint("BatteryLife") @RequiresApi(Build.VERSION_CODES.TIRAMISU) diff --git a/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt b/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt index e5233e0..82efeab 100644 --- a/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt +++ b/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt @@ -1,7 +1,5 @@ package com.maary.liveinpeace.ui.theme -import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme @@ -41,7 +39,7 @@ fun LiveInPeaceTheme( content: @Composable () -> Unit ) { val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + dynamicColor -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } From a4365a219b16ddbfce297c528e2bd8eef64318aa Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:03:50 +0800 Subject: [PATCH 46/48] Fix: Restart ForegroundService if killed by system when QS tile is active This commit addresses an issue where the Quick Settings (QS) tile might indicate the `ForegroundService` is running, even if the system has killed it. Now, if the QS tile is clicked and `ForegroundService.IS_SERVICE_RUNNING` is false despite the preference indicating it should be running, the `ForegroundService` is restarted. The tile state is then updated to reflect the actual running state of the service. --- .../main/java/com/maary/liveinpeace/service/QSTileService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index a71acb8..c825962 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -109,7 +109,8 @@ class QSTileService: TileService() { // 记录显示服务在运行,但内存中它已停止。 // 这几乎可以肯定是服务被系统强杀了。 // 1. 修正错误的持久化记录 - preferenceRepository.setServiceRunning(false) + val intent = Intent(this@QSTileService, ForegroundService::class.java) + startForegroundService(intent) // 2. 用修正后的、正确的状态(false)来更新磁贴外观 updateTileState(false) } else { From 50618a80ca1ee6513d316fab8caf3c0f54bd0133 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:04:05 +0800 Subject: [PATCH 47/48] Update dependencies and version name This commit updates the following: - `versionName` to `2025.06.20_01` - `androidx.compose:compose-bom` to `2025.06.01` - `androidx.compose.material3:material3` to `1.4.0-alpha16` - `androidx.room` dependencies to version `2.7.2` - `androidx.work:work-runtime-ktx` to `2.10.2` --- app/build.gradle | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b502d1b..6a81e39 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,7 +24,7 @@ android { minSdk 31 targetSdk 35 versionCode 5 - versionName "2025.04.05-01" + versionName "2025.06.20_01" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -138,12 +138,12 @@ dependencies { implementation 'androidx.databinding:databinding-runtime:8.10.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.1' implementation 'androidx.activity:activity-compose:1.10.1' - implementation platform('androidx.compose:compose-bom:2025.06.00') + implementation platform('androidx.compose:compose-bom:2025.06.01') 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-alpha15' - androidTestImplementation platform('androidx.compose:compose-bom:2025.06.00') + implementation 'androidx.compose.material3:material3:1.4.0-alpha16' + androidTestImplementation platform('androidx.compose:compose-bom:2025.06.01') androidTestImplementation 'androidx.compose.ui:ui-test-junit4' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' @@ -159,17 +159,17 @@ dependencies { ksp "com.google.dagger:hilt-compiler:2.56.2" - implementation "androidx.room:room-runtime:2.7.1" - annotationProcessor "androidx.room:room-compiler:2.7.1" - implementation 'androidx.room:room-ktx:2.7.1' - ksp "androidx.room:room-compiler:2.7.1" + implementation "androidx.room:room-runtime:2.7.2" + annotationProcessor "androidx.room:room-compiler:2.7.2" + implementation 'androidx.room:room-ktx:2.7.2' + ksp "androidx.room:room-compiler:2.7.2" implementation "androidx.datastore:datastore-preferences:1.2.0-alpha02" 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.1" + implementation "androidx.work:work-runtime-ktx:2.10.2" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' From cb849e2c8e5b1c5b0679f1e37795db9baf42f418 Mon Sep 17 00:00:00 2001 From: Maary <24504742+Steve-Mr@users.noreply.github.com> Date: Fri, 20 Jun 2025 20:32:18 +0800 Subject: [PATCH 48/48] Refactor release process in CI This commit updates the Android CI workflow (`android.yml`) to improve the release process: - **Release Tagging and Naming:** Releases are now tagged and named using the current date and a daily incrementing count (e.g., `release-YYYY-MM-DD-01`, `Release YYYY-MM-DD`). This replaces the previous `v${{ github.run_number }}` scheme. - **Commit Log in Release Body:** The body of the GitHub release now includes the commit messages from the pull request using `steps.show_pr_commits.outputs.commits`. - **Simplified Telegram Message:** The `upload.py` script has been modified to remove the commit message from the Telegram notification, as this information is now included in the GitHub release notes. --- .github/scripts/upload.py | 1 - .github/workflows/android.yml | 19 +++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/scripts/upload.py b/.github/scripts/upload.py index 275253a..99245c3 100644 --- a/.github/scripts/upload.py +++ b/.github/scripts/upload.py @@ -67,7 +67,6 @@ def sendTextMessage(user_id, message, disable_preview=False): message = ( f"#app #apk\n" f"**版本:** `{version_name}`\n\n" - f"**更新内容:**\n{commit_message}\n\n" f"https://github.com/Steve-Mr/LiveInPeace" ) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e11c855..a7c199c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -59,6 +59,20 @@ jobs: name: LiveInPeace-icon-disabled-universal.apk path: ${{ env.APK_FILE_UNI_ICON_DISABLED }} + - name: Get current date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + + # 获取今天已有的 releases 数量,用于生成序号 + - name: Get number of today's releases + id: release_count + run: | + DATE=${{ steps.date.outputs.date }} + COUNT=$(gh release list --limit 100 | grep "$DATE" | wc -l) + COUNT=$((COUNT + 1)) + printf "count=%02d\n" "$COUNT" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} - name: Create Release id: create_release @@ -66,12 +80,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} with: - tag_name: v${{ github.run_number }} + tag_name: release-${{ steps.date.outputs.date }}-${{ steps.release_count.outputs.count }} + release_name: Release ${{ steps.date.outputs.date }} prerelease: true - release_name: Release v${{ github.run_number }} body: | ## Changes ${{ github.event.pull_request.body }} + ${{ steps.show_pr_commits.outputs.commits }} # --- 修改开始:更新上传到 Release 的逻辑 --- - uses: actions/upload-release-asset@v1