diff --git a/.github/scripts/upload.py b/.github/scripts/upload.py index e5fc3ef..99245c3 100644 --- a/.github/scripts/upload.py +++ b/.github/scripts/upload.py @@ -6,47 +6,69 @@ 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"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..a7c199c 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,62 @@ 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: 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 @@ -73,53 +80,55 @@ 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 - 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 +138,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 +152,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 +180,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 +195,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') }} diff --git a/app/build.gradle b/app/build.gradle old mode 100644 new mode 100755 index 715bca2..6a81e39 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,8 +3,13 @@ plugins { id 'org.jetbrains.kotlin.android' id 'kotlin-android' id 'com.google.devtools.ksp' + id 'kotlin-parcelize' + id 'org.jetbrains.kotlin.plugin.compose' + 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)) @@ -12,32 +17,54 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) android { namespace 'com.maary.liveinpeace' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "com.maary.liveinpeace" minSdk 31 - targetSdk 34 + targetSdk 35 versionCode 5 - versionName "2.3_beta" + versionName "2025.06.20_01" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { 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" } } @@ -62,55 +89,89 @@ 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.all { variant -> - variant.outputs.all { output -> - // ... - def abi = output.getFilter(com.android.build.OutputFile.ABI) - def apkName = "LiveInPeace-${abi}.apk" - if (abi.contains("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) } - outputFileName = apkName } } + } dependencies { - implementation 'androidx.core:core-ktx:1.13.1' - implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.core:core-ktx:1.16.0' + implementation 'androidx.appcompat:appcompat:1.7.1' 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.10.1' + 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.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-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' - implementation 'androidx.activity:activity-ktx:1.9.0' - implementation 'androidx.databinding:databinding-runtime:8.4.1' + // LiveData + 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.2" - def lifecycle_version = '2.8.0' - // 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.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" - def room_version = '2.6.1' + implementation("com.google.accompanist:accompanist-permissions:0.37.3") + implementation "androidx.compose.material:material-icons-extended:1.7.8" - implementation "androidx.room:room-runtime:$room_version" - annotationProcessor "androidx.room:room-compiler:$room_version" - implementation 'androidx.room:room-ktx:2.6.1' - ksp "androidx.room:room-compiler:$room_version" + implementation "androidx.work:work-runtime-ktx:2.10.2" 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml old mode 100644 new mode 100755 index 4c04858..5984ce6 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,74 +1,94 @@ - + - - - - - + + + + + + + - + + + - + + + + + + - + - - + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> - - + + - - + android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> - + - - - - - - - + - - @@ -76,10 +96,8 @@ - - \ No newline at end of file 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/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/ConnectionsApplication.kt b/app/src/main/java/com/maary/liveinpeace/ConnectionsApplication.kt deleted file mode 100644 index 50022c8..0000000 --- a/app/src/main/java/com/maary/liveinpeace/ConnectionsApplication.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.maary.liveinpeace - -import android.app.Application -import com.google.android.material.color.DynamicColors -import com.maary.liveinpeace.database.ConnectionRepository -import com.maary.liveinpeace.database.ConnectionRoomDatabase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob - -class ConnectionsApplication: Application() { - - val database by lazy { ConnectionRoomDatabase.getDatabase(this) } - val repository by lazy { ConnectionRepository(database.connectionDao()) } - - override fun onCreate() { - super.onCreate() - DynamicColors.applyToActivitiesIfAvailable(this) - } -} \ 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..d68b973 100644 --- a/app/src/main/java/com/maary/liveinpeace/Constants.kt +++ b/app/src/main/java/com/maary/liveinpeace/Constants.kt @@ -2,40 +2,23 @@ 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 - // 使用图像式图标 - 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" - // 设置通知 id - const val ID_NOTIFICATION_SETTINGS = 3 + // 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 EAR_PROTECTION_LOWER_THRESHOLD = 10 + const val EAR_PROTECTION_UPPER_THRESHOLD = 25 // 前台通知 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 - // 设置图像式图标 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" @@ -47,8 +30,10 @@ class Constants { // 前台服务状态改变广播 const val BROADCAST_ACTION_FOREGROUND = "com.maary.liveinpeace.ACTION_FOREGROUND_SERVICE_STATE" const val BROADCAST_FOREGROUND_INTENT_EXTRA = "isForegroundServiceRunning" - // 当音量操作动作太过频繁后等待时间 - const val REQUESTING_WAIT_MILLIS = 500 + // 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" + // 不同通知频道 ID const val CHANNEL_ID_DEFAULT = "LIP_FOREGROUND" const val CHANNEL_ID_SETTINGS = "LIP_SETTINGS" @@ -62,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/HistoryActivity.kt b/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt deleted file mode 100644 index 19043be..0000000 --- a/app/src/main/java/com/maary/liveinpeace/HistoryActivity.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.maary.liveinpeace - -import android.os.Bundle -import android.view.View -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -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.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 java.text.SimpleDateFormat -import java.time.LocalDate -import java.util.Calendar -import java.util.Locale - - -class HistoryActivity : AppCompatActivity(), DeviceMapChangeListener { - - private lateinit var binding: ActivityHistoryBinding - private val connectionViewModel: ConnectionViewModel by viewModels { - ConnectionViewModelFactory((application as ConnectionsApplication).repository) - } - private val currentAdapter = ConnectionListAdapter() - - override fun onResume() { - super.onResume() - ForegroundService.addDeviceMapChangeListener(this) - } - - override fun onPause() { - super.onPause() - ForegroundService.removeDeviceMapChangeListener(this) - } - - private fun currentConnectionsDuration(currentList: MutableList) : MutableList{ - 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 - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - binding = ActivityHistoryBinding.inflate(layoutInflater) - setContentView(binding.root) - - var pickedDate : String = LocalDate.now().toString() - - val connectionAdapter = ConnectionListAdapter() - - // Makes only dates from today forward selectable. - val constraintsBuilder = - CalendarConstraints.Builder() - .setValidator(DateValidatorPointBackward.now()) - - var datePicker = - MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.select_date) - .setCalendarConstraints(constraintsBuilder.build()) - .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) } - } - } - if (checkedId == R.id.button_summary) { - connectionViewModel.getSummaryOnDate(pickedDate).observe(this) { connections -> - connections.let { connectionAdapter.submitList(it) } - } - } - updateCurrentAdapter() - } - - 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) - updateHistoryList(binding.toggleHistory.checkedButtonId) - updateCurrentAdapter() - } - - binding.historyList.isNestedScrollingEnabled = false - binding.historyList.adapter = connectionAdapter - binding.historyList.layoutManager = LinearLayoutManager(this) - - binding.toggleHistory.check(R.id.button_timeline) - - binding.activityHistoryToolbar.setNavigationOnClickListener { - finish() - } - - binding.buttonCalendar.text = formatMillisecondsToDate(System.currentTimeMillis(), PATTERN_DATE_BUTTON) - - binding.buttonCalendar.setOnClickListener { - datePicker.show(supportFragmentManager, "MATERIAL_DATE_PICKER") - } - - binding.buttonCalendar.setOnLongClickListener{ - changeDate(System.currentTimeMillis()) - datePicker = MaterialDatePicker.Builder.datePicker() - .setTitleText(R.string.select_date) - .setCalendarConstraints(constraintsBuilder.build()) - .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) - .build() - true - } - - binding.currentList.isNestedScrollingEnabled = false - binding.currentList.adapter = currentAdapter - binding.currentList.layoutManager = LinearLayoutManager(this) - - updateCurrentAdapter() - - connectionViewModel.getAllConnectionsOnDate(pickedDate).observe(this) { connections -> - connections.let { connectionAdapter.submitList(it) } - } - - binding.toggleHistory.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (!isChecked) return@addOnButtonCheckedListener - updateHistoryList(checkedId) - } - - datePicker.addOnPositiveButtonClickListener { - changeDate(datePicker.selection) - } - } - - private fun updateCurrentAdapter(){ - currentAdapter.submitList(currentConnectionsDuration(ForegroundService.getConnections())) - if (currentAdapter.itemCount == 0){ - binding.titleCurrent.visibility = View.GONE - }else{ - binding.titleCurrent.visibility = View.VISIBLE - } - } - - 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())) - } - - private fun formatMillisecondsToDate(milliseconds: Long?, pattern: String): String { - val dateFormat = SimpleDateFormat(pattern, Locale.getDefault()) - val calendar = Calendar.getInstance() - if (milliseconds != null) { - calendar.timeInMillis = milliseconds - } - return dateFormat.format(calendar.time) - } -} \ No newline at end of file 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..157bd1d --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/LiveInPeaceApplication.kt @@ -0,0 +1,77 @@ +package com.maary.liveinpeace + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import com.google.android.material.color.DynamicColors +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 com.maary.liveinpeace.database.ConnectionRepository +import com.maary.liveinpeace.database.ConnectionRoomDatabase +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class LiveInPeaceApplication: Application() { + + val database by lazy { ConnectionRoomDatabase.getDatabase(this) } + val repository by lazy { ConnectionRepository(database.connectionDao()) } + + override fun onCreate() { + super.onCreate() + createNotificationChannels() + DynamicColors.applyToActivitiesIfAvailable(this) + } + + 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/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 new file mode 100644 index 0000000..4ff8aee --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/activity/HistoryActivity.kt @@ -0,0 +1,226 @@ +package com.maary.liveinpeace.activity + +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 +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.ConnectionListAdapter +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.LiveInPeaceApplication +import com.maary.liveinpeace.R +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 +import java.util.Calendar +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) + } + // 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() + // Register the receiver + registerConnectionsUpdateReceiver() + } + + override fun onPause() { + super.onPause() + // Unregister the receiver + unregisterReceiver(connectionsUpdateReceiver) + connectionsUpdateReceiver = null // Allow garbage collection + } + + // Calculates duration for currently connected items based on their connect time + private fun calculateCurrentConnectionsDuration(currentList: List): List { + val now = System.currentTimeMillis() + 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 + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + binding = ActivityHistoryBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Use DateTimeFormatter for LocalDate + // Define the formatter using the pattern from Constants + 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. + val constraintsBuilder = + CalendarConstraints.Builder() + .setValidator(DateValidatorPointBackward.now()) + + var datePicker = + MaterialDatePicker.Builder.datePicker() + .setTitleText(R.string.select_date) + .setCalendarConstraints(constraintsBuilder.build()) + .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) + .build() + + // 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) + } + listToObserve.observe(this) { connections -> + connections?.let { historyAdapter.submitList(it) } + } + // 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?) { + 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) + // 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 = historyAdapter + binding.historyList.layoutManager = LinearLayoutManager(this) + + binding.toggleHistory.check(R.id.button_timeline) + + binding.activityHistoryToolbar.setNavigationOnClickListener { + finish() + } + + binding.buttonCalendar.text = formatMillisecondsToDate(System.currentTimeMillis(), PATTERN_DATE_BUTTON) + + binding.buttonCalendar.setOnClickListener { + datePicker.show(supportFragmentManager, "MATERIAL_DATE_PICKER") + } + + 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()) + .setSelection(MaterialDatePicker.todayInUtcMilliseconds()) + .build() + true + } + + // Setup current connections RecyclerView + binding.currentList.isNestedScrollingEnabled = false + binding.currentList.adapter = currentAdapter + binding.currentList.layoutManager = LinearLayoutManager(this) + + // Initial state for the current list view + updateCurrentConnectionsView(emptyList()) // Start with empty, wait for broadcast + + // Initial load for history list + updateHistoryList(binding.toggleHistory.checkedButtonId) + + // Listener for history view toggle (Timeline vs Summary) + binding.toggleHistory.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { // Only react when a button becomes checked + updateHistoryList(checkedId) + } + } + + // Listener for Date Picker confirmation + datePicker.addOnPositiveButtonClickListener { selection -> + changeDate(selection) // selection should be Long? + } + } + + // 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() + 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/activity/MainActivity.kt b/app/src/main/java/com/maary/liveinpeace/activity/MainActivity.kt new file mode 100644 index 0000000..600fab7 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/activity/MainActivity.kt @@ -0,0 +1,22 @@ +package com.maary.liveinpeace.activity + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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 { + SettingsScreen() + } + } + } +} \ No newline at end of file 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..a3ab611 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/activity/WelcomeActivity.kt @@ -0,0 +1,26 @@ +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 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/Connection.kt b/app/src/main/java/com/maary/liveinpeace/database/Connection.kt index e2587cb..0e95b97 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,12 @@ package com.maary.liveinpeace.database +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import java.sql.Date +import kotlinx.parcelize.Parcelize +@Parcelize @Entity(tableName = "connection_table") data class Connection( @PrimaryKey(autoGenerate = true) val id: Int = 0, @@ -14,5 +16,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/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/database/PreferenceRepository.kt b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt new file mode 100755 index 0000000..a6af554 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/database/PreferenceRepository.kt @@ -0,0 +1,129 @@ +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.BuildConfig +import com.maary.liveinpeace.Constants +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 + +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_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_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) + } + + 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 isIconShown() : Flow { + return datastore.data.map { pref -> + pref[PREF_VISIBLE_IN_LAUNCHER] ?: BuildConfig.ICON_ENABLED + } + } + + suspend fun toggleIconVisibility() { + datastore.edit { pref -> + val currentState = pref[PREF_VISIBLE_IN_LAUNCHER] ?: BuildConfig.ICON_ENABLED + pref[PREF_VISIBLE_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/receiver/BootCompleteReceiver.kt b/app/src/main/java/com/maary/liveinpeace/receiver/BootCompleteReceiver.kt index 734f3c2..72bb03f 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,21 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import com.maary.liveinpeace.service.ForegroundService +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.maary.liveinpeace.BootWorker -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 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 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..78fdb54 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,459 +9,574 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioManager 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.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_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.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.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.DeviceMapChangeListener +import com.maary.liveinpeace.Constants import com.maary.liveinpeace.DeviceTimer 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 +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.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.Date +import java.util.concurrent.ConcurrentHashMap + +/** + * 前台服务,用于监控音频设备连接、实现护耳模式和管理通知。 + * + * 重构要点: + * 1. **封装和内聚**: 将相关逻辑(如权限检查、通知更新)封装到独立的辅助函数中,提高代码复用性。 + * 2. **常量化**: 消除魔法数字(如 PendingIntent 的请求码、设备类型 ID),代之以有意义的常量,增强可读性。 + * 3. **代码简化**: 简化广播接收器的实现,避免代码重复。 + * 4. **健壮性**: 在所有通知操作前添加统一的权限检查,确保在 Android 13+ 系统上的稳定性。 + * 5. **可读性**: 优化函数和变量命名,增加注释,使代码意图更清晰。 + * 6. **结构优化**: `audioDeviceCallback` 内部逻辑被拆分为更小、职责更单一的函数,降低了复杂度和嵌套。 + */ +@AndroidEntryPoint +class ForegroundService : Service() { -class ForegroundService: Service() { + companion object { - private lateinit var database: ConnectionRoomDatabase - private lateinit var connectionDao: ConnectionDao + @Volatile + var isRunning = false + private set + + private const val TAG = "ForegroundService" + + // 为 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 deviceTimerMap: MutableMap = mutableMapOf() + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val audioManager: AudioManager by lazy { + getSystemService(Context.AUDIO_SERVICE) as AudioManager + } - 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 - ) + @Inject + lateinit var preferenceRepository: PreferenceRepository + private lateinit var connectionDao: ConnectionDao private lateinit var volumeComment: Array - private lateinit var audioManager: AudioManager + // 使用 ConcurrentHashMap 保证多线程访问的安全性 + private val deviceMap = ConcurrentHashMap() + private val deviceTimerMap = ConcurrentHashMap() + private val protectionJobs = ConcurrentHashMap() + private val deviceMapMutex = Mutex() - companion object { - private var isForegroundServiceRunning = false + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service creating...") - @JvmStatic - fun isForegroundServiceRunning(): Boolean { - return isForegroundServiceRunning - } + initializeDependencies() + registerReceiversAndCallbacks() - private val deviceMap: MutableMap = mutableMapOf() + // 启动前台服务,并立即更新一次通知状态 + startForegroundWithNotification() + setServiceRunningState(true) + isRunning = true - // 在伴生对象中定义一个静态方法,用于其他类访问deviceMap - fun getConnections(): MutableList { - return deviceMap.values.toMutableList() - } + Log.d(TAG, "Service created successfully.") + } - private val deviceMapChangeListeners: MutableList = mutableListOf() + private fun initializeDependencies() { + connectionDao = ConnectionRoomDatabase.getDatabase(applicationContext).connectionDao() + volumeComment = resources.getStringArray(R.array.array_volume_comment) + } - fun addDeviceMapChangeListener(listener: DeviceMapChangeListener) { - deviceMapChangeListeners.add(listener) - } + @SuppressLint("UnspecifiedRegisterReceiverFlag") + private fun registerReceiversAndCallbacks() { + // 注册音频设备回调 + audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + + // 注册音量变化接收器 + val volumeFilter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") + registerReceiver(volumeChangeReceiver, volumeFilter) - fun removeDeviceMapChangeListener(listener: DeviceMapChangeListener) { - deviceMapChangeListeners.remove(listener) + // 注册休眠定时器更新接收器 + 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 notifyDeviceMapChange() { - deviceMapChangeListeners.forEach { listener -> - listener.onDeviceMapChanged(deviceMap) + @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 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 + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand received.") + // 确保服务被重新创建时,通知内容是最新的 + updateForegroundNotification() + return START_STICKY } - 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 - } + override fun onDestroy() { + Log.d(TAG, "Service destroying...") + + isRunning=false + // 在清理资源前,先更新服务状态 + 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 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)) - } + 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) } } - private fun saveDataWhenStop(){ + private fun saveDataForActiveConnections() { val disconnectedTime = System.currentTimeMillis() + val currentConnections = deviceMap.toMap() // 创建副本以安全遍历 + deviceMap.clear() - for ( (deviceName, connection) in deviceMap){ + currentConnections.forEach { (_, connection) -> + val connectedTime = connection.connectedTime ?: return@forEach + val connectionTime = disconnectedTime - connectedTime - val connectedTime = connection.connectedTime - val connectionTime = disconnectedTime - connectedTime!! - - CoroutineScope(Dispatchers.IO).launch { - connectionDao.insert( - Connection( - name = connection.name, - type = connection.type, - connectedTime = connection.connectedTime, + serviceScope.launch { + try { + val finalConnection = connection.copy( disconnectedTime = disconnectedTime, - duration = connectionTime, - date = connection.date + 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) + } } - deviceMap.remove(deviceName) } - return + broadcastConnectionsUpdate() } + /** + * 音频设备连接状态的回调处理。 + * 内部逻辑被拆分为多个辅助函数,以提高清晰度和可维护性。 + */ private val audioDeviceCallback = object : AudioDeviceCallback() { - @SuppressLint("MissingPermission") + private val CALLBACK_TAG = "AudioDeviceCallback" + + 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, + TYPE_UNKNOWN_DEVICE_28 // 使用常量代替魔法数字 + ) + override fun onAudioDevicesAdded(addedDevices: Array?) { + if (addedDevices.isNullOrEmpty()) return - val connectedTime = System.currentTimeMillis() - val sharedPreferences = getSharedPreferences(SHARED_PREF, Context.MODE_PRIVATE) - - // 在设备连接时记录设备信息和接入时间 - 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, - ) - ) { 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) - } - if (boolProtected){ - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_PROTECT, createProtectionNotification()) - } - } - } - // 执行其他逻辑,比如将设备信息保存到数据库或日志中 - } + serviceScope.launch { + val isWatchingEnabled = preferenceRepository.getWatchingState().first() + val isEarProtectionOn = preferenceRepository.isEarProtectionOn().first() - 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 + addedDevices.filterNot { shouldIgnoreDevice(it) }.forEach { deviceInfo -> + val deviceName = getDeviceName(deviceInfo) + val wasAdded = processNewDevice(deviceInfo, deviceName, isWatchingEnabled) + if (wasAdded && isEarProtectionOn) { + applyEarProtection(deviceName) + } } - } - - Log.v("MUTE_MAP", deviceMap.toString()) - - // Handle newly added audio devices - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + onDeviceListChanged() } } - @SuppressLint("MissingPermission") override fun onAudioDevicesRemoved(removedDevices: Array?) { + if (removedDevices.isNullOrEmpty()) return - // 在设备连接时记录设备信息和接入时间 - removedDevices?.forEach { deviceInfo -> - val deviceName = deviceInfo.productName.toString() - val disconnectedTime = System.currentTimeMillis() + serviceScope.launch { + removedDevices.filterNot { shouldIgnoreDevice(it) }.forEach { deviceInfo -> + val deviceName = getDeviceName(deviceInfo) + processRemovedDevice(deviceName)?.let { connectionToSave -> + saveConnectionToDatabase(connectionToSave) + } + } + onDeviceListChanged() + } + } - if (deviceMap.containsKey(deviceName)){ + private fun shouldIgnoreDevice(deviceInfo: AudioDeviceInfo): Boolean { + val deviceName = getDeviceName(deviceInfo) + // 忽略本机、无名设备或特定类型的设备 + return deviceName.isEmpty() || deviceName == Build.MODEL || deviceInfo.type in IGNORED_DEVICE_TYPES + } - val connectedTime = deviceMap[deviceName]?.connectedTime - val connectionTime = disconnectedTime - connectedTime!! + private fun getDeviceName(deviceInfo: AudioDeviceInfo): String { + return deviceInfo.productName?.toString()?.trim() ?: "" + } - if (connectionTime > ALERT_TIME){ - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(ID_NOTIFICATION_ALERT) - } + private suspend fun processNewDevice( + deviceInfo: AudioDeviceInfo, + deviceName: String, + isWatching: Boolean + ): Boolean { + var wasAdded = false + deviceMapMutex.withLock { + if (!deviceMap.containsKey(deviceName)) { + Log.d(CALLBACK_TAG, "Device Added: $deviceName") + deviceMap[deviceName] = Connection( + name = deviceName, + type = deviceInfo.type, + connectedTime = System.currentTimeMillis(), + disconnectedTime = null, + duration = null, + date = LocalDate.now().toString() + ) + wasAdded = true - 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 - ) - ) + if (isWatching) { + Log.d(CALLBACK_TAG, "Starting timer for $deviceName") + DeviceTimer(applicationContext, deviceName).also { + it.start() + deviceTimerMap[deviceName] = it } } - - deviceMap.remove(deviceName) - notifyDeviceMapChange() } + } + return wasAdded + } - 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) + private suspend fun processRemovedDevice(deviceName: String): Connection? { + var connectionToSave: Connection? = null + deviceMapMutex.withLock { + if (deviceMap.containsKey(deviceName)) { + Log.d(CALLBACK_TAG, "Device Removed: $deviceName") + + // 停止相关任务和计时器 + 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 > Constants.ALERT_TIME) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(Constants.ID_NOTIFICATION_ALERT) + } } } - // 执行其他逻辑,比如将设备信息保存到数据库或日志中 } + return connectionToSave + } - // Handle removed audio devices - with(NotificationManagerCompat.from(applicationContext)){ - notify(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(applicationContext)) + private suspend fun saveConnectionToDatabase(connection: Connection) { + try { + connectionDao.insert(connection) + Log.d(CALLBACK_TAG, "Saved connection data for ${connection.name}") + } catch (e: Exception) { + Log.e(CALLBACK_TAG, "Error saving connection data", e) } } - } - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - override fun onCreate() { - super.onCreate() + private fun applyEarProtection(deviceName: String) { + val protectionJob = serviceScope.launch { + try { + Log.d(CALLBACK_TAG, "Applying ear protection for $deviceName") + var protectionApplied = false - Log.v("MUTE_TEST", "ON_CREATE") + val threshold = preferenceRepository.getEarProtectionThreshold().first() - audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) + // 调整音量到安全范围 + while (getVolumePercentage() > threshold.last && isActive) { + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, 0) + protectionApplied = true + } + while (getVolumePercentage() < threshold.first && isActive) { + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, 0) + protectionApplied = true + } - // 注册音量变化广播接收器 - val filter = IntentFilter().apply { - addAction("android.media.VOLUME_CHANGED_ACTION") + if (protectionApplied) { + Log.d(CALLBACK_TAG, "Ear protection applied for $deviceName.") + showProtectionNotification() + } + } catch (e: CancellationException) { + Log.d(CALLBACK_TAG, "Protection job for $deviceName was cancelled.") + } finally { + protectionJobs.remove(deviceName) + } + } + protectionJobs[deviceName] = protectionJob } - registerReceiver(volumeChangeReceiver, filter) + } + + // --- 辅助函数 --- + + override fun onBind(intent: Intent?): IBinder? = null - val sleepFilter = IntentFilter().apply { - addAction(BROADCAST_ACTION_SLEEPTIMER_UPDATE) + /** + * 封装权限检查逻辑,提高代码复用性。 + */ + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return true } - registerReceiver(sleepReceiver, sleepFilter, RECEIVER_NOT_EXPORTED) + return ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } - database = ConnectionRoomDatabase.getDatabase(applicationContext) - connectionDao = database.connectionDao() - startForeground(ID_NOTIFICATION_FOREGROUND, createForegroundNotification(context = applicationContext)) - notifyForegroundServiceState(true) - Log.v("MUTE_TEST", "ON_CREATE_FINISH") + /** + * 统一更新前台服务通知的入口。 + */ + @SuppressLint("MissingPermission") + private fun updateForegroundNotification() { + if (!hasNotificationPermission()) { + Log.w(TAG, "Cannot update notification: Permission denied.") + return + } + NotificationManagerCompat.from(this).notify( + Constants.ID_NOTIFICATION_FOREGROUND, + createForegroundNotification(this) + ) } - 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 在被终止后能够自动重启 - return START_STICKY + /** + * 显示护耳模式已应用的通知。 + */ + @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) } - 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") - super.onDestroy() + private fun onDeviceListChanged() { + broadcastConnectionsUpdate() + updateForegroundNotification() } - override fun onBind(p0: Intent?): IBinder? { - TODO("Not yet implemented") + private fun broadcastConnectionsUpdate() { + val intent = Intent(Constants.BROADCAST_ACTION_CONNECTIONS_UPDATE).apply { + putParcelableArrayListExtra( + Constants.EXTRA_CONNECTIONS_LIST, + ArrayList(deviceMap.values) + ) + } + sendBroadcast(intent) } - private fun notifyForegroundServiceState(isRunning: Boolean) { - isForegroundServiceRunning = isRunning - val intent = Intent(BROADCAST_ACTION_FOREGROUND) - intent.putExtra(BROADCAST_FOREGROUND_INTENT_EXTRA, isRunning) + private fun setServiceRunningState(isRunning: Boolean) { + serviceScope.launch { + preferenceRepository.setServiceRunning(isRunning) + } + 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.") } - @SuppressLint("LaunchActivityFromNotification") - 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)) + 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 settingsIntent = Intent(this, SettingsReceiver::class.java).apply { - action = ACTION_NAME_SETTINGS + 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 } - val snoozePendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE) - - val actionSettings : NotificationCompat.Action = NotificationCompat.Action.Builder( - R.drawable.ic_baseline_settings_24, - resources.getString(R.string.settings), - snoozePendingIntent - ).build() + } - val sharedPreferences = getSharedPreferences( - SHARED_PREF, - Context.MODE_PRIVATE - ) + // --- 广播接收器 --- - var protectionActionTitle = R.string.protection - if (sharedPreferences != null){ - if (sharedPreferences.getBoolean(PREF_ENABLE_EAR_PROTECTION, false)){ - protectionActionTitle = R.string.dont_protect - } + private val volumeChangeReceiver = object : VolumeReceiver() { + override fun updateNotification(context: Context) { + updateForegroundNotification() } + } - val protectionIntent = Intent(this, SettingsReceiver::class.java).apply { - action = ACTION_TOGGLE_AUTO_CONNECTION_ADJUSTMENT + private val sleepReceiver = object : SleepReceiver() { + override fun updateNotification(context: Context) { + updateForegroundNotification() } - val protectionPendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, protectionIntent, PendingIntent.FLAG_IMMUTABLE) + } - val actionProtection : NotificationCompat.Action = NotificationCompat.Action.Builder( - R.drawable.ic_headphones_protection, - resources.getString(protectionActionTitle), - 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, - sleepTitle, - pendingSleepIntent - ).build() + private fun createForegroundNotification(context: Context): Notification { + val currentVolume = getVolumePercentage() + val volumeLevel = getVolumeLevel(currentVolume) + val comment = volumeComment.getOrElse(volumeLevel) { "Volume" } - 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) + val contentText = String.format( + resources.getString(R.string.current_volume_percent), + comment, + currentVolume + ) - // 将 Service 设置为前台服务,并创建一个通知 - return NotificationCompat.Builder(this, CHANNEL_ID_DEFAULT) + 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) - .setContentText(String.format( - resources.getString(R.string.current_volume_percent), - volumeComment[currentVolumeLevel], - currentVolume)) - .setSmallIcon(nIcon) .setOngoing(true) - .setContentIntent(pendingMuteIntent) + .setContentIntent(createMutePendingIntent(context)) .setPriority(NotificationCompat.PRIORITY_LOW) - .addAction(actionSettings) - .addAction(actionSleepTimer) - .addAction(actionProtection) - .setGroup(ID_NOTIFICATION_GROUP_FORE) - .setGroupSummary(false) + .setGroup(Constants.ID_NOTIFICATION_GROUP_FORE) + .addAction(createSettingsAction(context)) + .addAction(createSleepTimerAction(context)) .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) - .build() + private fun createSettingsAction(context: Context): NotificationCompat.Action { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + REQUEST_CODE_SETTINGS, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + return NotificationCompat.Action.Builder( + R.drawable.ic_baseline_settings_24, + resources.getString(R.string.settings), + pendingIntent + ).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) + 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) + + val intent = Intent(context, MuteMediaReceiver::class.java).apply { + action = Constants.BROADCAST_ACTION_SLEEPTIMER_TOGGLE } - else { - return IconCompat.createWithResource(context, volumeDrawableIds[currentVolumeLevel]) + 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() + } + + 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, 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 { + 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 +} 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 df92bda..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,13 +1,15 @@ 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 import android.os.Build import android.service.quicksettings.TileService -import com.maary.liveinpeace.HistoryActivity +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/service/QSTileService.kt b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt index 357eeac..c825962 100644 --- a/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt +++ b/app/src/main/java/com/maary/liveinpeace/service/QSTileService.kt @@ -1,160 +1,127 @@ 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 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 -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 -import com.maary.liveinpeace.Constants.Companion.SHARED_PREF 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 +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 val serviceScope = CoroutineScope( SupervisorJob() + Dispatchers.IO) + private lateinit var preferenceRepository: PreferenceRepository + + override fun onCreate() { + super.onCreate() + val entryPoint = EntryPointAccessors.fromApplication( + applicationContext, + PreferenceQSTileEntryPoint::class.java + ) + preferenceRepository = entryPoint.preferenceRepository() + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + @SuppressLint("StartActivityAndCollapseDeprecated") @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onClick() { super.onClick() 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) - ) + var intent = Intent(this, WelcomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - 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( - applicationContext, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - Log.v("MUTE_", waitMillis.toString()) - requestNotificationsPermission() - Thread.sleep(waitMillis.toLong()) - 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) + serviceScope.launch { + 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) } - break + return@launch + } + intent = Intent(this@QSTileService, ForegroundService::class.java) + if (preferenceRepository.isServiceRunning().first()) { + stopService(intent) + preferenceRepository.setServiceRunning(false) + updateTileState(false) + tile.updateTile() } else { - createWelcomeNotification() - Thread.sleep(waitMillis.toLong()) - waitMillis *= 2 + startForegroundService(intent) + preferenceRepository.setServiceRunning(true) + updateTileState(true) + tile.updateTile() } } - - - val intent = Intent(this, ForegroundService::class.java) - - if (!ForegroundService.isForegroundServiceRunning()){ - 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{ - 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) - } tile.updateTile() } @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onStartListening() { super.onStartListening() + + // --- 核心校准逻辑开始 --- + // 每次磁贴可见时,检查持久化状态和内存状态是否一致 + serviceScope.launch { + // 从 PreferenceRepository 读取“预期状态” + val expectedState = preferenceRepository.isServiceRunning().first() + // 直接从内存中读取服务的“实际状态” + val actualState = ForegroundService.isRunning + // 对比两个状态 + if (expectedState && !actualState) { + // **发现不一致!** + // 记录显示服务在运行,但内存中它已停止。 + // 这几乎可以肯定是服务被系统强杀了。 + // 1. 修正错误的持久化记录 + val intent = Intent(this@QSTileService, ForegroundService::class.java) + startForegroundService(intent) + // 2. 用修正后的、正确的状态(false)来更新磁贴外观 + updateTileState(false) + } else { + // 状态一致,一切正常。直接按预期状态更新磁贴即可。 + updateTileState(expectedState) + } + } + // --- 核心校准逻辑结束 --- + val intentFilter = IntentFilter() - intentFilter.addAction(BROADCAST_ACTION_FOREGROUND) + intentFilter.addAction(Constants.BROADCAST_ACTION_FOREGROUND) registerReceiver(foregroundServiceReceiver, intentFilter, RECEIVER_NOT_EXPORTED) } @@ -165,82 +132,26 @@ 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 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 updateTileState(isRunning: Boolean) { + val tile = qsTile ?: return // Tile might be null if called before ready - 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) + 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) } - } - - @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()) - + tile.updateTile() } } \ 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 new file mode 100644 index 0000000..5e737a7 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsComponents.kt @@ -0,0 +1,215 @@ +package com.maary.liveinpeace.ui.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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 +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.maary.liveinpeace.ui.theme.Typography + +@Composable +fun TextContent(modifier: Modifier = Modifier, title: String, description: String? = null, color: Color = MaterialTheme.colorScheme.secondary) { + Column(modifier = modifier){ + Text( + title, + style = Typography.titleLarge, + color = color, + ) + if (description != null) { + Text( + description, + style = Typography.bodySmall, + maxLines = 5, + color = color + ) + } + } +} + +@Composable +fun SwitchRow( + title: String, + description: String? = null, + state: Boolean, + switchColor: Color = MaterialTheme.colorScheme.secondary, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!state) } + .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, color = switchColor) + Spacer(modifier = Modifier.width(8.dp)) + Switch(checked = state, onCheckedChange = onCheckedChange, colors = SwitchDefaults.colors(checkedTrackColor = switchColor)) + } +} + +@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(bottom = 8.dp), + color = MaterialTheme.colorScheme.secondary + ) + + // 内部状态的初始化逻辑保持不变 + var sliderPosition by remember { mutableStateOf(range.first.toFloat()..range.last.toFloat()) } + + // ✨ 新增 LaunchedEffect 来同步外部和内部的状态 + // 当 `range` 参数发生变化时,这个代码块会重新执行 + LaunchedEffect(range) { + sliderPosition = range.first.toFloat()..range.last.toFloat() + } + + RangeSlider( + modifier = Modifier.fillMaxWidth(), + 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 + ) + }, + 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 + ) + ) + } +} + +@Composable +private fun ValueIndicatorThumb( + value: Float, + enabled: Boolean +) { + // 根据可用状态选择颜色 + 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) { + // 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 的字体样式 + textAlign = TextAlign.Center, + modifier = Modifier.width(24.dp).padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + + // 5. 滑块本身 (圆点) + Box( + modifier = Modifier + .height(35.dp) // MD3 默认的滑块大小 + .width(4.dp) + .background(color = SliderDefaults.colors().thumbColor) + ) + } +} + +// 定义设置项在分组中的位置 +enum class GroupPosition { + TOP, // 顶部 + MIDDLE, // 中间 + BOTTOM, // 底部 + SINGLE // 独立,自成一组 +} + +@Composable +fun SettingsItem( + position: GroupPosition, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow, + 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) // 上下都是大圆角 + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(shape) // 动态应用形状 + .background(containerColor), + contentAlignment = Alignment.Center + ) { + content() + } +} \ 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..a9d2a5b --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/SettingsScreen.kt @@ -0,0 +1,214 @@ +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 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.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) +@Composable +fun SettingsScreen(settingsViewModel: SettingsViewModel = viewModel()) { + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + 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 = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.inversePrimary, + titleContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + actionIconContentColor = MaterialTheme.colorScheme.primary + ), + title = { + Text(stringResource(R.string.app_name), + style = Typography.titleLarge.copy( + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic + ) + ) + }, + navigationIcon = { + 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 = { + val intent = Intent(context, HistoryActivity::class.java) + context.startActivity(intent) + } ) { + Icon(painter = painterResource(R.drawable.ic_action_history), + contentDescription = stringResource(R.string.connections_history)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.inversePrimary) + ){ + + /** + * 1. 启用状态 + * 2. 提醒状态 + * 2.1 提醒时间 todo + * 3. 音量保护 + * 3.1 安全音量阈值 + * 4. 隐藏桌面图标 + * */ + + val isProtectionOn by settingsViewModel.protectionSwitchState.collectAsState() + val isForegroundEnabled by settingsViewModel.foregroundSwitchState.collectAsState() + + Spacer(modifier = Modifier.height(16.dp + innerPadding.calculateTopPadding())) + + SettingsItem( + position = if (isForegroundEnabled) GroupPosition.TOP else GroupPosition.SINGLE, + containerColor = MaterialTheme.colorScheme.tertiaryContainer) { + SwitchRow( + title = stringResource(id = R.string.default_channel), + state = isForegroundEnabled, + onCheckedChange = { settingsViewModel.foregroundSwitch() }, + switchColor = MaterialTheme.colorScheme.tertiary) + } + + AnimatedVisibility(visible = isForegroundEnabled) { + SettingsItem( position = GroupPosition.BOTTOM, + containerColor = MaterialTheme.colorScheme.tertiaryContainer) { + 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), + color = MaterialTheme.colorScheme.tertiary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + SettingsItem(GroupPosition.TOP, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { + SwitchRow( + title = stringResource(R.string.enable_watching), + description = stringResource(R.string.enable_watching_detail), + state = settingsViewModel.alertSwitchState.collectAsState().value, + onCheckedChange = { settingsViewModel.alertSwitch() } + ) + } + + SettingsItem(GroupPosition.MIDDLE, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { + SwitchRow( + title = stringResource(R.string.protection), + description = stringResource(R.string.protection_detail), + state = isProtectionOn, + onCheckedChange = { settingsViewModel.protectionSwitch() } + ) + } + + // 使用 AnimatedVisibility 包裹需要条件显示的组件 + AnimatedVisibility(visible = isProtectionOn) { + SettingsItem(GroupPosition.MIDDLE, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { + ThresholdSlider( + title = stringResource(id = R.string.safe_volume_threshold), + range = settingsViewModel.earProtectionThreshold.collectAsState().value, + onValueChangeFinished = { settingsViewModel.setEarProtectionThreshold(it) }, + ) + } + } + + SettingsItem(GroupPosition.BOTTOM, + containerColor = MaterialTheme.colorScheme.secondaryContainer) { + 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 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..a56b718 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/screen/WelcomeScreen.kt @@ -0,0 +1,192 @@ +package com.maary.liveinpeace.ui.screen + +import android.annotation.SuppressLint +import android.app.Activity +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.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.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.Modifier +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.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.viewmodel.WelcomeViewModel + +@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.inversePrimary, + titleContentColor = MaterialTheme.colorScheme.primary, + navigationIconContentColor = 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 + .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 , 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) + } + } + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.optional_permissions), + style = TextStyle( + fontWeight = FontWeight.Bold + ), + color = MaterialTheme.colorScheme.primary + ) + SettingsItem(GroupPosition.TOP) { + 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) + } + } + SettingsItem(GroupPosition.BOTTOM) { + 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() + val intent = Intent(context, MainActivity::class.java) + context.startActivity(intent) + }) { + Text(stringResource(R.string.finish)) + } + } + Spacer(Modifier.height(innerPadding.calculateBottomPadding())) + } + } +} + 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..82efeab --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/ui/theme/Theme.kt @@ -0,0 +1,56 @@ +package com.maary.liveinpeace.ui.theme + +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 -> { + 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/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 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..2c61601 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/SettingsViewModel.kt @@ -0,0 +1,130 @@ +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.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.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class SettingsViewModel @Inject constructor( + @ApplicationContext private val application: Context, + private val preferenceRepository: PreferenceRepository +): ViewModel() { + + val foregroundSwitchState: StateFlow = preferenceRepository.isServiceRunning() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + fun foregroundSwitch() { + if(!foregroundSwitchState.value) { + enableForegroundService() + } else { + disableForegroundService() + } + } + + private fun checkAndSyncServiceState() { + viewModelScope.launch { + // 获取预期的状态(来自持久化存储) + val expectedState = foregroundSwitchState.first() + + // 获取服务的真实状态(来自内存) + val actualState = ForegroundService.isRunning + + // 如果预期“开启”,但服务实际“停止”,说明服务曾被强杀 + if (expectedState && !actualState) { + // -> 自动重新启动服务,以恢复到用户想要的开启状态 + enableForegroundService() + } + } + } + + private fun enableForegroundService() { + viewModelScope.launch { + val intent = Intent(application, ForegroundService::class.java) + application.startForegroundService(intent) + preferenceRepository.setServiceRunning(true) + } + } + + private fun disableForegroundService() { + viewModelScope.launch { + val intent = Intent(application, ForegroundService::class.java) + application.stopService(intent) + preferenceRepository.setServiceRunning(false) + } + } + + val alertSwitchState: StateFlow = preferenceRepository.getWatchingState() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + fun alertSwitch() { + viewModelScope.launch { + preferenceRepository.setWatchingState(!alertSwitchState.value) + } + } + + val protectionSwitchState: StateFlow = preferenceRepository.isEarProtectionOn() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + fun protectionSwitch() { + viewModelScope.launch { + preferenceRepository.setEarProtection(!protectionSwitchState.value) + } + } + + val showIconState: StateFlow = preferenceRepository.isIconShown() + .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 enabledState = if (newState) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) + + // 2. 将新状态通知 Repository + preferenceRepository.toggleIconVisibility() + } + } + + 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) + } + } + + init { + checkAndSyncServiceState() + } + +} \ No newline at end of file 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..5e47d62 --- /dev/null +++ b/app/src/main/java/com/maary/liveinpeace/viewmodel/WelcomeViewModel.kt @@ -0,0 +1,76 @@ +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.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +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) + } + + val showIconState: StateFlow = preferenceRepository.isIconShown() + .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 enabledState = if (newState) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + packageManager.setComponentEnabledSetting(componentName, enabledState, PackageManager.DONT_KILL_APP) + + // 2. 将新状态通知 Repository + preferenceRepository.toggleIconVisibility() + } + } + + fun welcomeFinished() { + viewModelScope.launch { + preferenceRepository.setWelcomeFinished(true) + } + val intent = Intent(application, ForegroundService::class.java) + startForegroundService(application, intent) + } + +} \ No newline at end of file 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 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 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 4cb13ab..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,4 +51,24 @@ Is that ASMR pleasing? Let\'s ROCK + MainActivity + Notification Icon Type + Choose notification icon type from percent and image. + Notification Settings + 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 + 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 diff --git a/build.gradle b/build.gradle old mode 100644 new mode 100755 index 8e89450..23e565f --- a/build.gradle +++ b/build.gradle @@ -1,7 +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.5.1' apply false - id 'com.android.library' version '8.5.1' 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 '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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2724bc9..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.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists