diff --git a/app/build.gradle b/app/build.gradle index 857d809..ef81f2f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,6 +62,7 @@ protobuf { dependencies { implementation project(':base') + implementation project(':notification') testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/src/main/java/com/gamedog/vididin/main/MainActivity.kt b/app/src/main/java/com/gamedog/vididin/main/MainActivity.kt index e01788f..40bf468 100644 --- a/app/src/main/java/com/gamedog/vididin/main/MainActivity.kt +++ b/app/src/main/java/com/gamedog/vididin/main/MainActivity.kt @@ -11,6 +11,7 @@ import androidx.viewpager2.widget.ViewPager2 import android.view.LayoutInflater import androidx.activity.addCallback import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope import com.ama.core.architecture.appBase.AppViewsActivity import com.ama.core.architecture.appBase.OnFragmentBackgroundListener import com.ama.core.architecture.ext.toast @@ -26,6 +27,9 @@ import com.gamedog.vididin.adapter.MainViewPagerAdapter import com.gamedog.vididin.main.fragments.task.DailySignSuccessDialog import com.gamedog.vididin.main.interfaces.OnTabStyleListener import com.gamedog.vididin.manager.DateChangeReceiver +import com.gamedog.vididin.manager.NotificationController +import com.remax.base.utils.ActivityLauncher +import com.remax.notification.service.NotificationKeepAliveServiceManager import dagger.hilt.android.AndroidEntryPoint import kotlin.getValue import com.vididin.real.money.game.databinding.ActivityMainBinding as ViewBinding @@ -38,6 +42,7 @@ import com.gamedog.vididin.main.MainViewModel as ViewModel @AndroidEntryPoint class MainActivity : AppViewsActivity(), OnTabStyleListener { + private lateinit var activityLauncher: ActivityLauncher override val mViewModel: ViewModel by viewModels() private lateinit var navigatorAdapter: MainTabsAdapter private val fragmentStateAdapter by lazy { MainViewPagerAdapter(this) } @@ -116,7 +121,18 @@ class MainActivity : AppViewsActivity(), OnTabS } override fun ViewBinding.initObservers() { + activityLauncher = ActivityLauncher(this@MainActivity) + NotificationController.requestNotificationPermissionAsAsync( + context = this@MainActivity, + lifecycleScope = lifecycleScope, + activityLauncher = activityLauncher!!, + position = "Appstart", + onGrantedOnlyUnauthorized = { isGranted-> + if(isGranted){ + NotificationKeepAliveServiceManager.startKeepAliveService(this@MainActivity) + } + }) } diff --git a/app/src/main/java/com/gamedog/vididin/manager/NotificationController.kt b/app/src/main/java/com/gamedog/vididin/manager/NotificationController.kt new file mode 100644 index 0000000..3ed16fc --- /dev/null +++ b/app/src/main/java/com/gamedog/vididin/manager/NotificationController.kt @@ -0,0 +1,187 @@ +package com.gamedog.vididin.manager + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.core.app.ActivityCompat +import androidx.lifecycle.LifecycleCoroutineScope +import com.blankj.utilcode.util.StringUtils +import com.remax.base.controller.SystemPageNavigationController +import com.remax.base.ext.KvBoolDelegate +import com.remax.base.ext.KvLongDelegate +import com.remax.base.ext.canSendNotification +import com.remax.base.ext.requestNotificationPermission +import com.remax.base.report.DataReportManager +import com.remax.base.utils.ActivityLauncher +import com.vididin.real.money.game.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.Calendar +import kotlin.coroutines.resume + +object NotificationController { + private var lowVersionNotificationGranted by KvBoolDelegate( + "notification_lowVersionNotificationGr", + false + ) + private var lastNotificationDialogTime by KvLongDelegate("notification_last_dialog_time", 0L) + + /** + * 检查是否可以显示通知权限对话框(每天最多显示一次) + * @return true 如果可以显示,false 如果不可以显示 + */ + private fun canShowNotificationDialog(): Boolean { + val currentTime = System.currentTimeMillis() + + // 如果从未显示过,可以显示 + if (lastNotificationDialogTime == 0L) { + return true + } + + // 检查是否在同一天内已经显示过 + val lastShowDate = Calendar.getInstance().apply { + timeInMillis = lastNotificationDialogTime + } + val currentDate = Calendar.getInstance().apply { + timeInMillis = currentTime + } + + // 检查是否是同一天 + val isSameDay = + lastShowDate.get(Calendar.YEAR) == currentDate.get(Calendar.YEAR) && + lastShowDate.get(Calendar.DAY_OF_YEAR) == currentDate.get(Calendar.DAY_OF_YEAR) + + return !isSameDay + } + + private fun trackNotificationCompat(context: Activity) { + // 有权限时,如是低版本的,说明是自动授权,埋点上报一次 + if (context.canSendNotification() && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (!lowVersionNotificationGranted) { + lowVersionNotificationGranted = true + DataReportManager.reportData( + "Notific_Allow_Start", mapOf("Notific_Allow_Position" to "Appstart") + ) + DataReportManager.reportData( + "Notific_Allow_Result", mapOf( + "Notific_Allow_Position" to "Appstart", "Result" to "allow1" + ) + ) + } + } + } + + // 阻塞请求 + suspend fun requestNotificationPermissionAsWait( + context: Activity, + activityLauncher: ActivityLauncher, + onGrantedOnlyUnauthorized: (isGranted: Boolean) -> Unit + ): Boolean { + // 低版本埋点 + trackNotificationCompat(context) + // 高版本请求 + if (!context.canSendNotification()) { + DataReportManager.reportData( + "Notific_Allow_Start", mapOf("Notific_Allow_Position" to "Appstart") + ) + return runCatching { + suspendCancellableCoroutine { continuation -> + context.requestNotificationPermission(activityLauncher!!) { isGranted -> + DataReportManager.reportData( + "Notific_Allow_Result", mapOf( + "Notific_Allow_Position" to "Appstart", + "Result" to if (isGranted) "allow" else if (ActivityCompat.shouldShowRequestPermissionRationale( + context, Manifest.permission.POST_NOTIFICATIONS + ) + ) "denied" else "deined_forever" + ) + ) + onGrantedOnlyUnauthorized.invoke(isGranted) + if (continuation.isActive) { + continuation.resume(isGranted) + } + } + } + }.getOrDefault(false) + } + return true + } + + // 异步请求 + fun requestNotificationPermissionAsAsync( + context: Activity, + lifecycleScope: LifecycleCoroutineScope, + activityLauncher: ActivityLauncher, + position: String, + onGrantedOnlyUnauthorized: (isGranted: Boolean) -> Unit + ) { + lifecycleScope.launch { + delay(300) + if (!context.canSendNotification() /*&& canShowNotificationDialog()*/) { + // 记录显示时间 + lastNotificationDialogTime = System.currentTimeMillis() + + /*ConfirmationDialog.showNotification( + context = context, + title = StringUtils.getString(R.string.notification_dialog_title), + message = StringUtils.getString(R.string.notification_dialog_message), + onConfirm = { + runCatching { realRequest(context, activityLauncher, position, onGrantedOnlyUnauthorized) } + .onFailure { it.printStackTrace() } + })*/ + runCatching { realRequest(context, activityLauncher, position, onGrantedOnlyUnauthorized) } + + } + } + } + + private fun realRequest( + context: Activity, + activityLauncher: ActivityLauncher, + position: String, + onGrantedOnlyUnauthorized: (Boolean) -> Unit + ) { + DataReportManager.reportData( + "Notific_Allow_Start", mapOf("Notific_Allow_Position" to position) + ) + // android判断是否永久拒绝的api存在故意设计 + // 权限弹框用户点了返回键也算是一次拒绝 + if (ActivityCompat.shouldShowRequestPermissionRationale( + context, Manifest.permission.POST_NOTIFICATIONS + ) + ) { + context.requestNotificationPermission(activityLauncher!!) { isGranted -> + DataReportManager.reportData( + "Notific_Allow_Result", mapOf( + "Notific_Allow_Position" to "Appstart", + "Result" to if (isGranted) "allow" else "denied" + ) + ) + onGrantedOnlyUnauthorized.invoke(isGranted) + } + + + } else { + SystemPageNavigationController.markEnterNotificationAccessPage() + activityLauncher.launch(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + }) { + // 权限检查完成后,标记离开系统页面 + SystemPageNavigationController.markLeaveSystemPage() + // 检查通知权限状态并回调结果 + val isGranted = context.canSendNotification() + DataReportManager.reportData( + "Notific_Allow_Result", mapOf( + "Notific_Allow_Position" to "Appstart", + "Result" to if (isGranted) "allow" else "denied_forever" + ) + ) + onGrantedOnlyUnauthorized.invoke(isGranted) + } + + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8767e40..8b15d5a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,7 @@ databindingRuntime = "8.12.1" fragmentKtx = "1.8.9" workRuntime = "2.9.0" firebaseBom = "34.1.0" +lifecycleProcess = "2.8.3" [libraries] @@ -138,6 +139,8 @@ androidx-databinding-runtime = { group = "androidx.databinding", name = "databin androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } + firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-config = { group = "com.google.firebase", name = "firebase-config" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } @@ -147,6 +150,7 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly + [plugins] # android androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/notification/.gitignore b/notification/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/notification/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/notification/build.gradle.kts b/notification/build.gradle.kts new file mode 100644 index 0000000..7df8773 --- /dev/null +++ b/notification/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +val appConfig = findProperty("app") as Map<*, *> + + + +android { + namespace = "com.remax.notification" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + viewBinding = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.espresso.core) + + // Gson for JSON parsing + implementation(libs.gson) + + // WorkManager for background tasks + implementation(libs.androidx.work.runtime.ktx) + + // Startup for WorkManager initialization + implementation(libs.startup) + implementation(libs.androidx.lifecycle.process) + implementation(project(":base")) + + // Firebase Messaging for FCM + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + +} \ No newline at end of file diff --git a/notification/consumer-rules.pro b/notification/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/notification/proguard-rules.pro b/notification/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/notification/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/notification/src/androidTest/java/com/remax/notification/ExampleInstrumentedTest.kt b/notification/src/androidTest/java/com/remax/notification/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..2561f19 --- /dev/null +++ b/notification/src/androidTest/java/com/remax/notification/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.remax.notification + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.remax.notification.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/notification/src/main/AndroidManifest.xml b/notification/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9e5c464 --- /dev/null +++ b/notification/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notification/src/main/assets/push_config.json b/notification/src/main/assets/push_config.json new file mode 100644 index 0000000..60c516f --- /dev/null +++ b/notification/src/main/assets/push_config.json @@ -0,0 +1,26 @@ +{ + "paid_channel": { + "total_push_count": 999, + "unlock_push_interval": "10", + "background_push_interval": "10", + "hover_duration_strategy_switch": 1, + "hover_duration_loop_count": 8, + "new_user_cooldown": 0, + "do_not_disturb_start": "02:00", + "do_not_disturb_end": "08:00", + "notification_enabled": 1, + "keepalive_polling_interval_minutes": 15 + }, + "organic_channel": { + "total_push_count": 3, + "unlock_push_interval": "10", + "background_push_interval": "10", + "hover_duration_strategy_switch": 0, + "hover_duration_loop_count": 0, + "new_user_cooldown": "24", + "do_not_disturb_start": "02:00", + "do_not_disturb_end": "08:00", + "notification_enabled": 1, + "keepalive_polling_interval_minutes": 15 + } +} diff --git a/notification/src/main/assets/push_content_config.json b/notification/src/main/assets/push_content_config.json new file mode 100644 index 0000000..cc8ccb9 --- /dev/null +++ b/notification/src/main/assets/push_content_config.json @@ -0,0 +1,66 @@ +[ + { + "id": "push_001_v2", + "title": "💔 Accidentally Deleted a Photo? Recover It NOW!", + "desc": "That one special memory might be gone forever. Tap to restore it before it's too late!", + "buttonText": "RECOVER NOW", + "iconType": 1, + "actionType": 1 + }, + { + "id": "push_002_v2", + "title": "🎉 We Found 12 Deleted Vacation Videos!", + "desc": "Relive those moments. Tap to restore them before they are permanently overwritten.", + "buttonText": "SAVE MY VIDEOS", + "iconType": 2, + "actionType": 2 + }, + { + "id": "push_003_v2", + "title": "⚡️ Your Phone is Full! Clear 2 GB of Screenshots!", + "desc": "Stop struggling with low storage. Instantly find and remove old screenshots in one tap.", + "buttonText": "CLEAN NOW", + "iconType": 5, + "actionType": 5 + }, + { + "id": "push_004_v2", + "title": "📄 Important Work File Disappeared? We Can Help!", + "desc": "Your crucial documents are still recoverable. Tap to preview and restore them in seconds.", + "buttonText": "RESTORE FILES", + "iconType": 3, + "actionType": 3 + }, + { + "id": "push_005_v2", + "title": "🎶 47 Lost Songs? Your Playlist Is Waiting!", + "desc": "We just detected a lot of deleted songs. Tap to bring your favorite tunes back to life.", + "buttonText": "RESTORE SONGS", + "iconType": 4, + "actionType": 4 + }, + { + "id": "push_006_v2", + "title": "✨ Instantly See Your Recoverable Files", + "desc": "Get a sneak peek of all the files you can recover right now, without a full scan.", + "buttonText": "VIEW FILES", + "iconType": 6, + "actionType": 6 + }, + { + "id": "push_007_v2", + "title": "⏳ Act Now! Files Expire In 24 Hours", + "desc": "This is your last chance to rescue your photos and documents before they are permanently lost.", + "buttonText": "RESCUE NOW", + "iconType": 1, + "actionType": 1 + }, + { + "id": "push_008_v2", + "title": "🤝 Join 50K+ Happy Users Who Restored Their Memories", + "desc": "You're one tap away from getting back what you thought was gone forever. Start your scan now.", + "buttonText": "START SCAN", + "iconType": 1, + "actionType": 1 + } +] \ No newline at end of file diff --git a/notification/src/main/java/com/remax/notification/builder/NotificationDataFactory.kt b/notification/src/main/java/com/remax/notification/builder/NotificationDataFactory.kt new file mode 100644 index 0000000..6b91949 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/builder/NotificationDataFactory.kt @@ -0,0 +1,339 @@ +package com.remax.notification.builder + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.widget.RemoteViews +import com.blankj.utilcode.util.StringUtils +import com.example.features.notification.controller.NotificationRemoteViewsBuilder +import com.remax.base.utils.FileScanner +import com.remax.notification.R +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.config.PushContent +import com.remax.notification.config.PushContentController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.service.NotificationKeepAliveServiceManager +import java.io.File +import java.time.LocalDate +import kotlin.random.Random + + +enum class NotificationType { + GENERAL, + RESIDENT, RESIDENT_RESTORED, RESIDENT_RESTORED_FILE, + WIDGET_SCREENSHOT_CLEAN,WIDGET_PHOTO_RECOVERY,WIDGET_PHOTO_DOCUMENT +} + +val type2notificationId = mapOf( + NotificationType.GENERAL to 10000, + NotificationType.RESIDENT to 10001, + NotificationType.RESIDENT_RESTORED to 10002, + NotificationType.RESIDENT_RESTORED_FILE to 10003, + NotificationType.WIDGET_SCREENSHOT_CLEAN to 10004, + NotificationType.WIDGET_PHOTO_RECOVERY to 10005, + NotificationType.WIDGET_PHOTO_DOCUMENT to 10006, +) + +val LANDING_NOTIFICATION_ID = "landing_notification_id" +val LANDING_NOTIFICATION_ACTION = "landing_notification_action" +val LANDING_NOTIFICATION_FROM = "landing_notification_from" +val LANDING_NOTIFICATION_TITLE = "landing_notification_title" +val LANDING_NOTIFICATION_CONTENT = "landing_notification_content" + +/** + * 通知数据对象 + */ +class GeneralNotificationData( + val notificationId: Int, + val contentTitle: String, + val contentContent: String, + val contentIntent: PendingIntent? = null, + val contentView: RemoteViews? = null, + val bigContentView: RemoteViews? = null, +) + +fun entryPointPendingIntent( + context: Context, + notificationId: Int, + applyIntent: ((Intent) -> Unit)? = null, +): PendingIntent { + val intent = entryPointIntent(context) + intent.putExtra(LANDING_NOTIFICATION_ID, notificationId) + applyIntent?.invoke(intent) + + + val options = if (Build.VERSION.SDK_INT >= 35) { + ActivityOptions.makeBasic().apply { + setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ) + }.toBundle() + } else { + null + } + + return PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + options + ) +} + +fun entryPointIntent(context: Context): Intent = + Intent().apply { + // 使用 Action 方式跳转,避免直接依赖外部 Activity + action = "com.remax.notification.ACTION_OPEN_APP" + addCategory(Intent.CATEGORY_DEFAULT) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + // 设置包名,确保跳转到正确的应用 + setPackage(context.packageName) + // 添加通知来源标识 + putExtra("from_notification", true) + putExtra("notification_timestamp", System.currentTimeMillis()) + } + +/** + * 重置红点显示状态(用于测试或特殊情况) + * @param context 上下文 + */ +fun resetRedPointStatus(context: Context) { + try { + val sharedPreferences = + context.getSharedPreferences("notification_red_point", Context.MODE_PRIVATE) + sharedPreferences.edit() + .remove("has_clicked_today") + .remove("last_click_date") + .apply() + } catch (e: Exception) { + // 忽略异常 + } +} + +/** + * 检查是否应该显示红点(点击后才隐藏) + * @param context 上下文 + * @return true 如果应该显示红点,false 如果不应该显示 + */ +private fun shouldShowRedPoint(context: Context): Boolean { + return try { + val sharedPreferences = + context.getSharedPreferences("notification_red_point", Context.MODE_PRIVATE) + val hasClickedToday = sharedPreferences.getBoolean("has_clicked_today", false) + val lastClickDate = sharedPreferences.getString("last_click_date", "") + val today = LocalDate.now().toString() + + // 如果是新的一天,重置点击状态 + if (lastClickDate != today) { + sharedPreferences.edit() + .putBoolean("has_clicked_today", false) + .putString("last_click_date", today) + .apply() + return true // 新的一天显示红点 + } + + // 如果今天还没点击过,显示红点 + !hasClickedToday + } catch (e: Exception) { + // 异常情况下默认显示红点 + true + } +} + +/** + * 标记红点已点击(隐藏红点) + * @param context 上下文 + */ +fun markRedPointClicked(context: Context) { + try { + val sharedPreferences = + context.getSharedPreferences("notification_red_point", Context.MODE_PRIVATE) + val today = LocalDate.now().toString() + sharedPreferences.edit() + .putBoolean("has_clicked_today", true) + .putString("last_click_date", today) + .apply() + + NotificationKeepAliveServiceManager.startKeepAliveService(context) + } catch (e: Exception) { + // 忽略异常 + } +} + +/** + * 计算恢复文件数量 + * @param context 上下文 + * @return 文件数量,如果出错则返回 0 + */ +private fun getRecoveredFileCount(context: Context): Int { + return try { + val externalStorage = Environment.getExternalStorageDirectory() + val copyDir = File(externalStorage, FileScanner.COPY_DIR) + + if (!copyDir.exists() || !copyDir.isDirectory) { + return 0 + } + + var fileCount = 0 + copyDir.listFiles()?.forEach { subDir -> + if (subDir.isDirectory) { + subDir.listFiles()?.let { files -> + fileCount += files.size + } + } + } + fileCount + } catch (e: Exception) { + // 忽略无权限等异常情况 + 0 + } +} + +class GeneralModelManager() { + + fun getModel(context: Context,type:NotificationCheckController.NotificationType): GeneralNotificationData { + val notificationId = type2notificationId[NotificationType.GENERAL] ?: 0 + val data = PushContentController.getNextPushContent()!! + val title = data.title + val content = data.desc + val pendingIntent = entryPointPendingIntent(context, notificationId) { + it.putExtra(LANDING_NOTIFICATION_ACTION, data.actionType) + it.putExtra(LANDING_NOTIFICATION_FROM, type.string) + it.putExtra(LANDING_NOTIFICATION_TITLE, title) + it.putExtra(LANDING_NOTIFICATION_CONTENT, content) + } + + val badgeCount = Random.nextInt(1, 100).toString() + val contentView = NotificationRemoteViewsBuilder( + context.packageName, + R.layout.layout_notification_general_12, + R.layout.layout_notification_general + ) + .setImageViewResource( + R.id.iv, when (data.iconType) { + PushContent.ICON_TYPE_PHOTO -> R.drawable.ic_noti_photo + PushContent.ICON_TYPE_VIDEO -> R.drawable.ic_noti_video + PushContent.ICON_TYPE_DOCUMENT -> R.drawable.ic_noti_document + PushContent.ICON_TYPE_AUDIO -> R.drawable.ic_noti_audio + PushContent.ICON_TYPE_SCREENSHOT -> R.drawable.ic_noti_shot + PushContent.ICON_TYPE_RECOVERED -> R.drawable.ic_noti_recover + else -> R.drawable.ic_noti_photo + } + ) + .setTextViewText(R.id.tvCount, badgeCount) + .setTextViewText(R.id.tvTitle, title) + .setTextViewText(R.id.tvDesc, content) + .setTextViewText( + R.id.tvAction, when (data.iconType) { + PushContent.ICON_TYPE_SCREENSHOT -> StringUtils.getString(R.string.noti_clean) + else -> StringUtils.getString(R.string.noti_recovery) + } + ) + .build() + + val bigContentView = NotificationRemoteViewsBuilder( + context.packageName, + R.layout.layout_notification_general_big_12, + R.layout.layout_notification_general_big + ) + .setImageViewResource( + R.id.iv, when (data.iconType) { + PushContent.ICON_TYPE_PHOTO -> R.drawable.ic_noti_photo + PushContent.ICON_TYPE_VIDEO -> R.drawable.ic_noti_video + PushContent.ICON_TYPE_DOCUMENT -> R.drawable.ic_noti_document + PushContent.ICON_TYPE_AUDIO -> R.drawable.ic_noti_audio + PushContent.ICON_TYPE_SCREENSHOT -> R.drawable.ic_noti_shot + PushContent.ICON_TYPE_RECOVERED -> R.drawable.ic_noti_recover + else -> R.drawable.ic_noti_photo + } + ) + .setTextViewText(R.id.tvCount, badgeCount) + .setTextViewText(R.id.tvTitle, title) + .setTextViewText(R.id.tvDesc, content) + .setTextViewText( + R.id.tvAction, when (data.iconType) { + PushContent.ICON_TYPE_SCREENSHOT -> StringUtils.getString(R.string.noti_clean) + else -> StringUtils.getString(R.string.noti_recovery) + } + ) + .build() + + return GeneralNotificationData( + notificationId = notificationId, + contentTitle = title, + contentContent = content, + contentIntent = pendingIntent, + contentView = contentView, + bigContentView = bigContentView + ) + } +} + +class ResidentModelManger { + + fun getModel(context: Context): GeneralNotificationData { + + val contentView = NotificationRemoteViewsBuilder( + context.packageName, + R.layout.layout_notification_resident_12, + R.layout.layout_notification_resident + ) + .setViewVisible(R.id.ivPoint, shouldShowRedPoint(context)) + .setTextViewText( + R.id.files, + StringUtils.getString( + R.string.noti_restore_file_count, + getRecoveredFileCount(context).toString() + ) + ) + .setTextViewText( + R.id.restored, + StringUtils.getString( + R.string.noti_restored + ) + ) + .setTextViewText( + R.id.restore_file, + StringUtils.getString( + R.string.noti_restore_file + ) + ) + .setOnClickPendingIntent( + R.id.llRestored, entryPointPendingIntent( + context, + type2notificationId[NotificationType.RESIDENT_RESTORED] ?: 0 + ) { + it.putExtra( + LANDING_NOTIFICATION_ACTION, + PushContent.ACTION_TYPE_RECOVERED_FILES + ) + it.putExtra(LANDING_NOTIFICATION_FROM, NotificationCheckController.NotificationType.RESIDENT.string) + }) + .setOnClickPendingIntent( + R.id.llRestoreFiles, entryPointPendingIntent( + context, + type2notificationId[NotificationType.RESIDENT_RESTORED_FILE] ?: 0 + ) { + it.putExtra( + LANDING_NOTIFICATION_ACTION, + PushContent.ACTION_TYPE_DOCUMENT_RECOVERY + ) + it.putExtra(LANDING_NOTIFICATION_FROM, NotificationCheckController.NotificationType.RESIDENT.string) + }) + .build() + + + return GeneralNotificationData( + notificationId = type2notificationId[NotificationType.RESIDENT] ?: 0, + contentTitle = StringUtils.getString(R.string.noti_resident_title), + contentContent = StringUtils.getString(R.string.noti_resident_service_running), + contentIntent = null, + contentView = contentView, + bigContentView = null + ) + } +} diff --git a/notification/src/main/java/com/remax/notification/check/NotificationBlockReason.kt b/notification/src/main/java/com/remax/notification/check/NotificationBlockReason.kt new file mode 100644 index 0000000..8adc96f --- /dev/null +++ b/notification/src/main/java/com/remax/notification/check/NotificationBlockReason.kt @@ -0,0 +1,29 @@ +package com.remax.notification.check + +/** + * 通知拦截原因枚举 + */ +enum class NotificationBlockReason(val reason: String, val description: String) { + // 基础条件检查失败 + NO_PERMISSION("no_permission", "无通知权限"), + NOTIFICATION_DISABLED("notification_disabled", "通知开关已关闭"), + DO_NOT_DISTURB("do_not_disturb", "免打扰时间段"), + APP_IN_FOREGROUND("app_in_foreground", "应用在前台"), + DAILY_LIMIT_REACHED("daily_limit_reached", "达到每日通知次数限制"), + NEW_USER_COOLDOWN("new_user_cooldown", "新用户冷却时间"), + + // 触发间隔检查失败 + TRIGGER_INTERVAL_NOT_MET("trigger_interval_not_met", "触发间隔未满足"), + + // 其他原因 + UNKNOWN("unknown", "未知原因"); + + companion object { + /** + * 根据原因字符串获取枚举 + */ + fun fromReason(reason: String): NotificationBlockReason { + return values().find { it.reason == reason } ?: UNKNOWN + } + } +} diff --git a/notification/src/main/java/com/remax/notification/check/NotificationCheckController.kt b/notification/src/main/java/com/remax/notification/check/NotificationCheckController.kt new file mode 100644 index 0000000..6bd007a --- /dev/null +++ b/notification/src/main/java/com/remax/notification/check/NotificationCheckController.kt @@ -0,0 +1,380 @@ +package com.remax.notification.check + +import com.remax.notification.config.NotificationConfigController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.utils.ResetAtMidnightController +import com.blankj.utilcode.util.TimeUtils +import com.remax.base.ext.canSendNotification +import com.remax.base.report.DataReportManager +import com.remax.notification.timing.NotificationTimingController + +/** + * 通知检查控制器 + * 统一处理通知前的各种检查逻辑 + */ +class NotificationCheckController private constructor() { + + companion object { + private const val TAG = "NotificationCheckController" + + @Volatile + private var INSTANCE: NotificationCheckController? = null + + fun getInstance(): NotificationCheckController { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: NotificationCheckController().also { INSTANCE = it } + } + } + } + + // 使用0点重置控制器管理通知时间和计数 + private val resetController = ResetAtMidnightController.getInstance() + + // 通知时间相关的键名常量 + private object Keys { + const val LAST_UNLOCK_NOTIFICATION_TIME = "last_unlock_notification_time" + const val LAST_BACKGROUND_NOTIFICATION_TIME = "last_background_notification_time" + const val CURRENT_NOTIFICATION_COUNT = "current_notification_count" + } + + // 通知计数相关 + private var context: android.content.Context? = null + private var cachedInstallTime: Long = 0L + + /** + * 通知类型枚举 + */ + enum class NotificationType(val string: String) { + UNLOCK("local_push"), // 解锁通知 + BACKGROUND("local_push"), // 后台通知 + KEEPALIVE("local_push"), // 保活通知(无间隔限制) + FCM("firebase_push"), // FCM 推送通知(无间隔限制) + RESIDENT("top_notification") // 常驻(无间隔限制) + } + + /** + * 初始化通知检查控制器 + * @param context 上下文 + */ + fun initialize(context: android.content.Context) { + if (this.context != null) { + NotiLogger.d("通知检查控制器已经初始化") + return + } + + this.context = context.applicationContext + resetController.initialize(context) + NotiLogger.d("通知检查控制器初始化完成") + } + + /** + * 检查是否可以触发指定类型的通知,并返回拦截原因 + * @param type 通知类型 + * @return 检查结果,包含是否可以触发和拦截原因 + */ + fun canTriggerNotificationWithReason(type: NotificationType): Pair { + // 1. 检查基础条件(通知权限、开关、免打扰、前台状态、总次数限制、新用户冷却时间) + val basicConditionResult = checkAllConditionsWithReason() + if (!basicConditionResult.first) { + return Pair(false, basicConditionResult.second) + } + + // 2. 检查触发间隔 + val intervalResult = checkTriggerIntervalWithReason(type) + if (!intervalResult.first) { + return Pair(false, intervalResult.second) + } + + return Pair(true, null) + } + + /** + * 检查所有触发条件,并返回拦截原因 + * @return 检查结果,包含是否满足所有条件和拦截原因 + */ + private fun checkAllConditionsWithReason(): Pair { + + // 检查通知次数限制 + val totalCount = NotificationConfigController.getTotalPushCount() + val currentCount = getCurrentNotificationCount() + if (currentCount >= totalCount) { + NotiLogger.d("已达到通知次数限制: $currentCount/$totalCount") + return Pair(false, NotificationBlockReason.DAILY_LIMIT_REACHED) + } + + // 检查通知权限 + if (!canSendNotification()) { + NotiLogger.d("无通知权限") + return Pair(false, NotificationBlockReason.NO_PERMISSION) + } + + // 检查通知开关 + if (!NotificationConfigController.isNotificationEnabled()) { + NotiLogger.d("通知开关已关闭") + return Pair(false, NotificationBlockReason.NOTIFICATION_DISABLED) + } + + // 检查免打扰时间段 + if (NotificationConfigController.isInDoNotDisturbTime()) { + val startTime = NotificationConfigController.getDoNotDisturbStartTime() + val endTime = NotificationConfigController.getDoNotDisturbEndTime() + NotiLogger.d("当前在免打扰时间段内 (${startTime}-${endTime})") + return Pair(false, NotificationBlockReason.DO_NOT_DISTURB) + } + + // 检查应用前台状态 + if (NotificationTimingController.getInstance().isAppInForeground()) { + NotiLogger.d("通知检查未通过:应用在前台,不发送通知") + return Pair(false, NotificationBlockReason.APP_IN_FOREGROUND) + } + + // 检查新用户冷却时间 + val cooldownMinutes = NotificationConfigController.getNewUserCooldownMin() + if (cooldownMinutes > 0) { + val currentTime = System.currentTimeMillis() + val cooldownMs = cooldownMinutes * 60 * 1000L + val appInstallTime = getAppInstallTime() + + if (currentTime - appInstallTime < cooldownMs) { + val remainingMinutes = ((cooldownMs - (currentTime - appInstallTime)) / (60 * 1000)).toInt() + NotiLogger.d("新用户冷却时间未满足,还需等待 ${remainingMinutes} 分钟 (冷却时长: ${cooldownMinutes} 分钟)") + return Pair(false, NotificationBlockReason.NEW_USER_COOLDOWN) + } + } + + return Pair(true, null) + } + + /** + * 检查是否有通知权限 + * @return 是否有通知权限 + */ + private fun canSendNotification(): Boolean { + val context = context ?: return false + + return try { + context.canSendNotification() + } catch (e: Exception) { + NotiLogger.e("检查通知权限失败", e) + false + } + } + + /** + * 检查触发间隔,并返回拦截原因 + * @param type 通知类型 + * @return 检查结果,包含是否满足触发间隔要求和拦截原因 + */ + private fun checkTriggerIntervalWithReason(type: NotificationType): Pair { + val currentTime = System.currentTimeMillis() + val lastTime = getLastNotificationTimeInternal(type) + val intervalMs = getNotificationInterval(type) + + // 如果是第一次触发,或者距离上次触发已经超过间隔时间 + val canTrigger = lastTime == 0L || (currentTime - lastTime) >= intervalMs + + if (!canTrigger) { + val remainingMinutes = ((lastTime + intervalMs - currentTime) / (60 * 1000)).toInt() + val intervalMinutes = (intervalMs / (60 * 1000)).toInt() + NotiLogger.d("${type.name}通知间隔未满足,还需等待 ${remainingMinutes} 分钟 (间隔时长: ${intervalMinutes} 分钟)") + return Pair(false, NotificationBlockReason.TRIGGER_INTERVAL_NOT_MET) + } + + return Pair(true, null) + } + + /** + * 获取应用安装时间(通过包信息获取,带缓存) + * @return 应用安装时间戳 + */ + private fun getAppInstallTime(): Long { + // 如果已经缓存了安装时间,直接返回 + if (cachedInstallTime > 0) { + return cachedInstallTime + } + + return try { + val packageInfo = context?.packageManager?.getPackageInfo( + context?.packageName ?: "", + 0 + ) + + // firstInstallTime 是应用首次安装的时间(Android P+) + val installTime = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + packageInfo?.firstInstallTime ?: System.currentTimeMillis() + } else { + // Android P 以下版本使用 lastUpdateTime 作为近似值 + packageInfo?.lastUpdateTime ?: System.currentTimeMillis() + } + + // 缓存安装时间 + cachedInstallTime = installTime + + NotiLogger.d("应用安装时间: ${TimeUtils.millis2String(installTime)}") + installTime + } catch (e: Exception) { + NotiLogger.e("获取应用安装时间失败", e) + // 如果获取失败,返回24小时前的时间(表示已经过了冷却期) + val fallbackTime = System.currentTimeMillis() - (24 * 60 * 60 * 1000L) + cachedInstallTime = fallbackTime + fallbackTime + } + } + + /** + * 获取指定类型通知的最后触发时间(内部使用) + * @param type 通知类型 + * @return 最后触发时间 + */ + private fun getLastNotificationTimeInternal(type: NotificationType): Long { + return when (type) { + NotificationType.UNLOCK -> resetController.getLongValue(Keys.LAST_UNLOCK_NOTIFICATION_TIME) + NotificationType.BACKGROUND -> resetController.getLongValue(Keys.LAST_BACKGROUND_NOTIFICATION_TIME) + else -> 0L + } + } + + /** + * 获取指定类型通知的间隔时间(毫秒) + * @param type 通知类型 + * @return 间隔时间(毫秒) + */ + private fun getNotificationInterval(type: NotificationType): Long { + return when (type) { + NotificationType.UNLOCK -> NotificationConfigController.getUnlockPushIntervalMin() * 60 * 1000L + NotificationType.BACKGROUND -> NotificationConfigController.getBackgroundPushIntervalMin() * 60 * 1000L + else -> 0L + } + } + + /** + * 记录通知触发时间 + * @param type 通知类型 + */ + fun recordNotificationTrigger(type: NotificationType) { + val currentTime = System.currentTimeMillis() + when (type) { + NotificationType.UNLOCK -> resetController.setLongValue(Keys.LAST_UNLOCK_NOTIFICATION_TIME, currentTime) + NotificationType.BACKGROUND -> resetController.setLongValue(Keys.LAST_BACKGROUND_NOTIFICATION_TIME, currentTime) + else -> { + // 其它,因为无间隔限制 + NotiLogger.d("${type.name}通知触发,不记录时间") + } + } + NotiLogger.d("${type.name}通知触发时间: ${TimeUtils.millis2String(currentTime, "yyyy-MM-dd HH:mm:ss")}") + } + + /** + * 获取当前通知次数(不进行0点重置) + * @return 当前通知次数 + */ + private fun getCurrentNotificationCount(): Int { + return resetController.getIntValue(Keys.CURRENT_NOTIFICATION_COUNT, enableMidnightReset = false) + } + + /** + * 增加通知计数(外部调用) + */ + fun incrementNotificationCount() { + val newCount = resetController.incrementIntValue(Keys.CURRENT_NOTIFICATION_COUNT, enableMidnightReset = false) + NotiLogger.d("通知计数增加: $newCount") + } + + /** + * 重置所有通知时间记录 + */ + fun resetAllNotificationTimes() { + resetController.resetValue(Keys.LAST_UNLOCK_NOTIFICATION_TIME) + resetController.resetValue(Keys.LAST_BACKGROUND_NOTIFICATION_TIME) + NotiLogger.d("重置所有通知时间记录") + } + + /** + * 重置指定类型的通知时间记录 + * @param type 通知类型 + */ + fun resetNotificationTime(type: NotificationType) { + when (type) { + NotificationType.UNLOCK -> resetController.resetValue(Keys.LAST_UNLOCK_NOTIFICATION_TIME) + NotificationType.BACKGROUND -> resetController.resetValue(Keys.LAST_BACKGROUND_NOTIFICATION_TIME) + else -> { + // 其它,无需重置 + NotiLogger.d("${type.name}通知无时间记录,无需重置") + } + } + NotiLogger.d("重置${type.name}通知时间记录") + } + + /** + * 获取指定类型通知的最后触发时间(外部接口) + * @param type 通知类型 + * @return 最后触发时间 + */ + fun getLastNotificationTime(type: NotificationType): Long { + return getLastNotificationTimeInternal(type) + } + + /** + * 获取指定类型通知的剩余等待时间(分钟) + * @param type 通知类型 + * @return 剩余等待时间(分钟) + */ + fun getRemainingWaitTime(type: NotificationType): Int { + val currentTime = System.currentTimeMillis() + val lastTime = getLastNotificationTimeInternal(type) + val intervalMs = getNotificationInterval(type) + + if (lastTime == 0L) return 0 + + val remainingMs = lastTime + intervalMs - currentTime + return if (remainingMs > 0) (remainingMs / (60 * 1000)).toInt() else 0 + } + + /** + * 重置今日通知计数 + */ + fun resetTodayNotificationCount() { + resetController.resetValue(Keys.CURRENT_NOTIFICATION_COUNT, enableMidnightReset = false) + NotiLogger.d("重置通知计数") + } + + /** + * 获取今日通知计数 + * @return 今日已发送的通知数量 + */ + fun getTodayNotificationCount(): Int { + return resetController.getIntValue(Keys.CURRENT_NOTIFICATION_COUNT, enableMidnightReset = false) + } + + /** + * 获取剩余可发送通知数量 + * @return 剩余可发送数量 + */ + fun getRemainingNotificationCount(): Int { + val totalCount = NotificationConfigController.getTotalPushCount() + val currentCount = getTodayNotificationCount() + return maxOf(0, totalCount - currentCount) + } + + /** + * 检查是否还有剩余通知次数 + * @return 是否还有剩余次数 + */ + fun hasRemainingNotificationCount(): Boolean { + return getRemainingNotificationCount() > 0 + } + + /** + * 获取通知统计信息 + * @return 通知统计信息字符串 + */ + fun getNotificationStats(): String { + val totalCount = NotificationConfigController.getTotalPushCount() + val currentCount = getTodayNotificationCount() + val remainingCount = getRemainingNotificationCount() + val installTime = getAppInstallTime() + val installDate = java.time.Instant.ofEpochMilli(installTime) + + return "今日已发送: $currentCount, 总限制: $totalCount, 剩余: $remainingCount, 安装时间: $installDate" + } +} diff --git a/notification/src/main/java/com/remax/notification/config/NotificationConfigController.kt b/notification/src/main/java/com/remax/notification/config/NotificationConfigController.kt new file mode 100644 index 0000000..dfe68bf --- /dev/null +++ b/notification/src/main/java/com/remax/notification/config/NotificationConfigController.kt @@ -0,0 +1,272 @@ +package com.remax.notification.config + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.remax.base.controller.UserChannelController +import com.remax.base.ext.KvStringDelegate +import com.remax.base.utils.RemoteConfigManager +import com.remax.notification.service.NotificationKeepAliveService +import com.remax.notification.utils.NotiLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException +import kotlin.math.max + +/** + * 推送通知配置控制器 + */ +object NotificationConfigController { + + private const val TAG = "NotificationConfigController" + private const val CONFIG_FILE_NAME = "push_config.json" + + private var config: NotificationConfig? = null + private var configJsonFromRemote by KvStringDelegate("notificationConfigJsonRemote", "") + + /** + * 初始化配置 + * @param context 上下文 + * @return 是否初始化成功 + */ + fun initialize(context: Context): Boolean { + return try { + val jsonString = configJsonFromRemote.orEmpty().takeIf { it.isNotEmpty() }?:loadConfigFromAssets(context) + config = parseConfig(jsonString) + NotiLogger.d("配置初始化成功,当前使用渠道: ${UserChannelController.getCurrentChannel().value}") + setKeepAliveInterval() + + // 异步获取远程配置 + fetchRemotePushConfig() + + // 监听用户渠道变化 + setupChannelListener() + + true + } catch (e: Exception) { + NotiLogger.e("配置初始化失败", e) + false + } + } + + /** + * 设置渠道监听器 + */ + private fun setupChannelListener() { + UserChannelController.addChannelChangeListener(object : UserChannelController.ChannelChangeListener { + override fun onChannelChanged(oldChannel: UserChannelController.UserChannelType, newChannel: UserChannelController.UserChannelType) { + NotiLogger.d("通知渠道变化: ${oldChannel.value} -> ${newChannel.value}") + // 渠道变化时,可以在这里做一些额外的处理 + // 比如重新加载配置、清理缓存等 + } + }) + } + + /** + * 异步获取远程推送配置 + */ + private fun fetchRemotePushConfig() { + CoroutineScope(Dispatchers.IO).launch { + try { + NotiLogger.d("开始获取远程推送配置") + val remoteJsonString = RemoteConfigManager.getString("pushConfigJson", "") + + if (remoteJsonString != null && remoteJsonString.isNotEmpty()) { + NotiLogger.d("成功获取远程推送配置") + val remoteConfig = parseConfig(remoteJsonString) + + // 更新本地配置 + config = remoteConfig + configJsonFromRemote = remoteJsonString + setKeepAliveInterval() + NotiLogger.d("远程推送配置更新成功") + } else { + NotiLogger.w("远程推送配置为空或获取超时,使用本地配置") + } + + } catch (e: Exception) { + NotiLogger.e("获取远程推送配置异常", e) + } + } + } + + fun setKeepAliveInterval(){ + runCatching { + val seconds = max( + getKeepalivePollingIntervalMinutes() * 60L, + NotificationKeepAliveService.defaultIntervalSeconds + ) + NotificationKeepAliveService.setDefaultIntervalSeconds1(seconds) + } + } + + /** + * 获取推送总次数 + * @return 推送总次数 + */ + fun getTotalPushCount(): Int { + return getCurrentConfig()?.totalPushCount ?: 0 + } + + /** + * 获取解锁推送间隔(分钟) + * @return 解锁推送间隔(分钟) + */ + fun getUnlockPushIntervalMin(): Int { + return getCurrentConfig()?.unlockPushInterval ?: 10 + } + + /** + * 获取压后台推送间隔(分钟) + * @return 压后台推送间隔(分钟) + */ + fun getBackgroundPushIntervalMin(): Int { + return getCurrentConfig()?.backgroundPushInterval ?: 10 + } + + /** + * 获取悬停时长策略开关 + * @return 悬停时长策略开关 (1: 开启, 0: 关闭) + */ + fun getHoverDurationStrategySwitch(): Int { + return getCurrentConfig()?.hoverDurationStrategySwitch ?: 0 + } + + /** + * 获取悬停时长循环次数 + * @return 悬停时长循环次数 + */ + fun getHoverDurationLoopCount(): Int { + return getCurrentConfig()?.hoverDurationLoopCount ?: 0 + } + + /** + * 获取新用户冷却时间(分钟) + * @return 新用户冷却时间(分钟) + */ + fun getNewUserCooldownMin(): Int { + return getCurrentConfig()?.newUserCooldown ?: 0 + } + + /** + * 获取免打扰开始时间 + * @return 免打扰开始时间 (格式: HH:mm) + */ + fun getDoNotDisturbStartTime(): String { + return getCurrentConfig()?.doNotDisturbStart ?: "02:00" + } + + /** + * 获取免打扰结束时间 + * @return 免打扰结束时间 (格式: HH:mm) + */ + fun getDoNotDisturbEndTime(): String { + return getCurrentConfig()?.doNotDisturbEnd ?: "08:00" + } + + /** + * 检查当前时间是否在免打扰时间段内 + * @return 是否在免打扰时间段内 + */ + fun isInDoNotDisturbTime(): Boolean { + val startTime = getDoNotDisturbStartTime() + val endTime = getDoNotDisturbEndTime() + + return try { + val currentTime = java.time.LocalTime.now() + val start = java.time.LocalTime.parse(startTime) + val end = java.time.LocalTime.parse(endTime) + + if (start <= end) { + // 同一天内的时间段 + currentTime >= start && currentTime <= end + } else { + // 跨天的时间段 (如 22:00-06:00) + currentTime >= start || currentTime <= end + } + } catch (e: Exception) { + NotiLogger.e("解析免打扰时间失败", e) + false + } + } + + /** + * 获取通知开关状态 + * @return 通知开关状态 (1: 开启, 0: 关闭) + */ + fun getNotificationEnabled(): Int { + return getCurrentConfig()?.notificationEnabled ?: 1 + } + + /** + * 检查通知是否开启 + * @return 通知是否开启 + */ + fun isNotificationEnabled(): Boolean { + return getNotificationEnabled() == 1 + } + + /** + * 获取保活轮询间隔(分钟) + * @return 保活轮询间隔(分钟) + */ + fun getKeepalivePollingIntervalMinutes(): Int { + return getCurrentConfig()?.keepalivePollingIntervalMinutes ?: 15 // 默认15分钟 + } + + /** + * 获取当前配置 + * @return 当前渠道的配置 + */ + private fun getCurrentConfig(): PushConfig? { + return when (UserChannelController.getCurrentChannel()) { + UserChannelController.UserChannelType.PAID -> config?.paidChannel + UserChannelController.UserChannelType.NATURAL -> config?.organicChannel + } + } + + /** + * 从 assets 加载配置文件 + * @param context 上下文 + * @return JSON 字符串 + */ + private fun loadConfigFromAssets(context: Context): String { + return try { + context.assets.open(CONFIG_FILE_NAME).bufferedReader().use { it.readText() } + } catch (e: IOException) { + NotiLogger.e("加载配置文件失败", e) + throw e + } + } + + /** + * 解析配置 JSON + * @param jsonString JSON 字符串 + * @return 配置对象 + */ + private fun parseConfig(jsonString: String): NotificationConfig { + return try { + Gson().fromJson(jsonString, NotificationConfig::class.java) + } catch (e: JsonSyntaxException) { + NotiLogger.e("解析配置文件失败", e) + throw e + } + } + + /** + * 检查配置是否已初始化 + * @return 是否已初始化 + */ + fun isInitialized(): Boolean { + return config != null + } + + /** + * 获取完整配置(用于调试) + * @return 完整配置对象 + */ + fun getFullConfig(): NotificationConfig? { + return config + } +} diff --git a/notification/src/main/java/com/remax/notification/config/PushConfig.kt b/notification/src/main/java/com/remax/notification/config/PushConfig.kt new file mode 100644 index 0000000..c44fa55 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/config/PushConfig.kt @@ -0,0 +1,39 @@ +package com.remax.notification.config + +import com.google.gson.annotations.SerializedName + +/** + * 推送配置数据类 + */ +data class PushConfig( + @SerializedName("total_push_count") + val totalPushCount: Int, + @SerializedName("unlock_push_interval") + val unlockPushInterval: Int, + @SerializedName("background_push_interval") + val backgroundPushInterval: Int, + @SerializedName("hover_duration_strategy_switch") + val hoverDurationStrategySwitch: Int, + @SerializedName("hover_duration_loop_count") + val hoverDurationLoopCount: Int, + @SerializedName("new_user_cooldown") + val newUserCooldown: Int, + @SerializedName("do_not_disturb_start") + val doNotDisturbStart: String, + @SerializedName("do_not_disturb_end") + val doNotDisturbEnd: String, + @SerializedName("notification_enabled") + val notificationEnabled: Int, + @SerializedName("keepalive_polling_interval_minutes") + val keepalivePollingIntervalMinutes: Int +) + +/** + * 完整配置数据类 + */ +data class NotificationConfig( + @SerializedName("paid_channel") + val paidChannel: PushConfig, + @SerializedName("organic_channel") + val organicChannel: PushConfig +) diff --git a/notification/src/main/java/com/remax/notification/config/PushContent.kt b/notification/src/main/java/com/remax/notification/config/PushContent.kt new file mode 100644 index 0000000..09aaeb1 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/config/PushContent.kt @@ -0,0 +1,103 @@ +package com.remax.notification.config + +import com.google.gson.annotations.SerializedName + +/** + * 推送通知内容数据类 + * iconType + * 1 代表 照片恢复 + * 2 代表 视频恢复 + * 3 代表 文档恢复 + * 4 代表 音频恢复 + * 5 代表 截图清理 + * 6 代表 已恢复文件列表 + * + * actionType + * 1 代表 跳转照片恢复 界面 + * 2 代表 跳转视频恢复 界面 + * 3 代表 跳转文档恢复 界面 + * 4 代表 跳转音频恢复 界面 + * 5 代表 跳转截图清理 界面 + * 6 代表 跳转已恢复文件列表 界面 + */ + +data class PushContent( + @SerializedName("id") + val id: String, + @SerializedName("title") + val title: String, + @SerializedName("desc") + val desc: String, + @SerializedName("buttonText") + val buttonText: String, + @SerializedName("iconType") + val iconType: Int, + @SerializedName("actionType") + val actionType: Int +) { + companion object { + /** + * 图标类型常量 + */ + const val ICON_TYPE_PHOTO = 1 // 照片恢复 + const val ICON_TYPE_VIDEO = 2 // 视频恢复 + const val ICON_TYPE_DOCUMENT = 3 // 文档恢复 + const val ICON_TYPE_AUDIO = 4 // 音频恢复 + const val ICON_TYPE_SCREENSHOT = 5 // 截图清理 + const val ICON_TYPE_RECOVERED = 6 // 已恢复文件列表 + + /** + * 动作类型常量 + */ + const val ACTION_TYPE_PHOTO_RECOVERY = 1 // 跳转照片恢复 界面 + const val ACTION_TYPE_VIDEO_RECOVERY = 2 // 跳转视频恢复 界面 + const val ACTION_TYPE_DOCUMENT_RECOVERY = 3 // 跳转文档恢复 界面 + const val ACTION_TYPE_AUDIO_RECOVERY = 4 // 跳转音频恢复 界面 + const val ACTION_TYPE_SCREENSHOT_CLEAN = 5 // 跳转截图清理 界面 + const val ACTION_TYPE_RECOVERED_FILES = 6 // 跳转已恢复文件列表 界面 + + /** + * 根据动作类型获取对应的图标类型 + */ + fun getIconTypeByActionType(actionType: Int): Int { + return when (actionType) { + ACTION_TYPE_PHOTO_RECOVERY -> ICON_TYPE_PHOTO + ACTION_TYPE_VIDEO_RECOVERY -> ICON_TYPE_VIDEO + ACTION_TYPE_DOCUMENT_RECOVERY -> ICON_TYPE_DOCUMENT + ACTION_TYPE_AUDIO_RECOVERY -> ICON_TYPE_AUDIO + ACTION_TYPE_SCREENSHOT_CLEAN -> ICON_TYPE_SCREENSHOT + ACTION_TYPE_RECOVERED_FILES -> ICON_TYPE_RECOVERED + else -> ICON_TYPE_PHOTO // 默认返回照片图标 + } + } + + /** + * 根据图标类型获取对应的动作类型 + */ + fun getActionTypeByIconType(iconType: Int): Int { + return when (iconType) { + ICON_TYPE_PHOTO -> ACTION_TYPE_PHOTO_RECOVERY + ICON_TYPE_VIDEO -> ACTION_TYPE_VIDEO_RECOVERY + ICON_TYPE_DOCUMENT -> ACTION_TYPE_DOCUMENT_RECOVERY + ICON_TYPE_AUDIO -> ACTION_TYPE_AUDIO_RECOVERY + ICON_TYPE_SCREENSHOT -> ACTION_TYPE_SCREENSHOT_CLEAN + ICON_TYPE_RECOVERED -> ACTION_TYPE_RECOVERED_FILES + else -> ACTION_TYPE_PHOTO_RECOVERY // 默认返回照片恢复动作 + } + } + + /** + * 检查动作类型是否有效 + */ + fun isValidActionType(actionType: Int): Boolean { + return actionType in ACTION_TYPE_PHOTO_RECOVERY..ACTION_TYPE_RECOVERED_FILES + } + + /** + * 检查图标类型是否有效 + */ + fun isValidIconType(iconType: Int): Boolean { + return iconType in ICON_TYPE_PHOTO..ICON_TYPE_RECOVERED + } + } +} diff --git a/notification/src/main/java/com/remax/notification/config/PushContentController.kt b/notification/src/main/java/com/remax/notification/config/PushContentController.kt new file mode 100644 index 0000000..653f8c8 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/config/PushContentController.kt @@ -0,0 +1,150 @@ +package com.remax.notification.config + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import com.remax.base.ext.KvStringDelegate +import com.remax.base.utils.RemoteConfigManager +import com.remax.notification.utils.NotiLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException + +/** + * 推送通知内容控制器 + */ +object PushContentController { + + private const val CONTENT_CONFIG_FILE_NAME = "push_content_config.json" + private const val SP_NAME = "push_content_prefs" + private const val KEY_CURRENT_INDEX = "current_index" + + private var pushContents: List? = null + private lateinit var sharedPreferences: SharedPreferences + private var contentJsonFromRemote by KvStringDelegate("notificationContentJsonRemote", "") + + /** + * 初始化内容配置 + * @param context 上下文 + * @return 是否初始化成功 + */ + fun initialize(context: Context): Boolean { + return try { + sharedPreferences = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE) + val jsonString = contentJsonFromRemote.orEmpty().takeIf { it.isNotEmpty() }?:loadContentConfigFromAssets(context) + pushContents = parseContentConfig(jsonString) + NotiLogger.d("推送内容配置初始化成功,共 ${pushContents?.size} 条") + + // 异步获取远程配置 + fetchRemotePushContent() + + true + } catch (e: Exception) { + NotiLogger.e("推送内容配置初始化失败", e) + false + } + } + + /** + * 获取下一个推送内容(顺序获取) + * @return 推送内容,首次调用返回第一条,之后按顺序返回 + */ + fun getNextPushContent(): PushContent? { + val contents = pushContents ?: return null + if (contents.isEmpty()) return null + + val currentIndex = getCurrentIndex() + val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % contents.size + + // 保存下一个索引 + saveCurrentIndex(nextIndex) + + val content = contents[nextIndex] + NotiLogger.d("获取推送内容: ${content.id}, 索引: $nextIndex") + + return content + } + + /** + * 检查是否已初始化 + * @return 是否已初始化 + */ + fun isInitialized(): Boolean { + return pushContents != null + } + + /** + * 异步获取远程推送内容配置 + */ + private fun fetchRemotePushContent() { + CoroutineScope(Dispatchers.IO).launch { + try { + NotiLogger.d("开始获取远程推送内容配置") + val remoteJsonString = RemoteConfigManager.getString("pushContentJson", "") + + if (remoteJsonString != null && remoteJsonString.isNotEmpty()) { + NotiLogger.d("成功获取远程推送内容配置") + val remoteContents = parseContentConfig(remoteJsonString) + + // 更新本地配置 + pushContents = remoteContents + contentJsonFromRemote = remoteJsonString + NotiLogger.d("远程推送内容配置更新成功,共 ${remoteContents.size} 条") + } else { + NotiLogger.w("远程推送内容配置为空或获取超时,使用本地配置") + } + + } catch (e: Exception) { + NotiLogger.e("获取远程推送内容配置异常", e) + } + } + } + + /** + * 从 assets 加载内容配置文件 + * @param context 上下文 + * @return JSON 字符串 + */ + private fun loadContentConfigFromAssets(context: Context): String { + return try { + context.assets.open(CONTENT_CONFIG_FILE_NAME).bufferedReader().use { it.readText() } + } catch (e: IOException) { + NotiLogger.e("加载推送内容配置文件失败", e) + throw e + } + } + + /** + * 解析内容配置 JSON + * @param jsonString JSON 字符串 + * @return 内容列表 + */ + private fun parseContentConfig(jsonString: String): List { + return try { + val type = object : TypeToken>() {}.type + Gson().fromJson(jsonString, type) + } catch (e: JsonSyntaxException) { + NotiLogger.e("解析推送内容配置文件失败", e) + throw e + } + } + + /** + * 获取当前索引 + * @return 当前索引,首次调用返回-1 + */ + private fun getCurrentIndex(): Int { + return sharedPreferences.getInt(KEY_CURRENT_INDEX, -1) + } + + /** + * 保存当前索引到 SharedPreferences + * @param index 索引 + */ + private fun saveCurrentIndex(index: Int) { + sharedPreferences.edit().putInt(KEY_CURRENT_INDEX, index).apply() + } +} diff --git a/notification/src/main/java/com/remax/notification/controller/NotificationLandingController.kt b/notification/src/main/java/com/remax/notification/controller/NotificationLandingController.kt new file mode 100644 index 0000000..1e741c1 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/controller/NotificationLandingController.kt @@ -0,0 +1,91 @@ +package com.remax.notification.controller + +import android.content.Context +import android.content.Intent +import com.remax.notification.builder.LANDING_NOTIFICATION_ACTION +import com.remax.notification.builder.LANDING_NOTIFICATION_FROM +import com.remax.notification.builder.LANDING_NOTIFICATION_ID +import com.remax.notification.builder.NotificationType +import com.remax.notification.builder.markRedPointClicked +import com.remax.notification.builder.type2notificationId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * 通知落地页控制器 + * 处理通知跳转和应用启动逻辑 + */ +object NotificationLandingController { + + private const val TAG = "NotificationLandingController" + + // 协程作用域,用于异步处理 + private val landingScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + /** + * 检查是否来自通知跳转 + * @param intent Intent 对象 + * @return true 如果来自通知跳转,false 如果不是 + */ + fun isFromNotification(intent: Intent): Boolean { + return intent.action == "com.remax.notification.ACTION_OPEN_APP" || + intent.getBooleanExtra("from_notification", false) + } + + + /** + * 获取通知动作类型 + * @param intent Intent 对象 + * @return 动作类型,如果没有则返回默认值 + */ + fun getNotificationActionType(intent: Intent): Int { + return intent.getIntExtra("landing_notification_action", 1) + } + + /** + * 获取通知时间戳 + * @param intent Intent 对象 + * @return 时间戳,如果没有则返回 0 + */ + fun getNotificationTimestamp(intent: Intent): Long { + return intent.getLongExtra("notification_timestamp", 0L) + } + + /** + * 检查是否需要标记红点已点击 + * @param intent Intent 对象 + * @return true 如果需要标记,false 如果不需要 + */ + fun shouldMarkRedPointClicked(intent: Intent): Boolean { + return intent.getBooleanExtra("mark_red_point_clicked", false) + } + + fun handleResidentRedPoint(context: Context, intent: Intent) { + if (intent.getIntExtra( + LANDING_NOTIFICATION_ID, + -1 + ) == type2notificationId[NotificationType.RESIDENT_RESTORED_FILE] + ) { + markRedPointClicked(context) + } + } + + /** + * 清理Intent中的通知相关参数 + * @param intent Intent 对象 + */ + fun clearNotificationParameters(intent: Intent) { + // 清理通知相关参数 + intent.removeExtra("from_notification") + intent.removeExtra(LANDING_NOTIFICATION_ACTION) + intent.removeExtra("notification_timestamp") + intent.removeExtra("mark_red_point_clicked") + intent.removeExtra(LANDING_NOTIFICATION_ID) + intent.removeExtra(LANDING_NOTIFICATION_FROM) + + // 清理通知动作 + intent.action = null + } + +} diff --git a/notification/src/main/java/com/remax/notification/controller/NotificationTriggerController.kt b/notification/src/main/java/com/remax/notification/controller/NotificationTriggerController.kt new file mode 100644 index 0000000..11b4326 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/controller/NotificationTriggerController.kt @@ -0,0 +1,442 @@ +package com.remax.notification.controller + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.remax.base.ext.canSendNotification +import com.remax.base.report.DataReportManager +import com.remax.notification.R +import com.remax.notification.builder.GeneralModelManager +import com.remax.notification.builder.GeneralNotificationData +import com.remax.notification.builder.NotificationType +import com.remax.notification.builder.ResidentModelManger +import com.remax.notification.builder.type2notificationId +import com.remax.notification.config.NotificationConfigController +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.receiver.NotificationDeleteReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 通知触发控制器 + * 提供常驻通知和普通通知的触发功能 + */ +@SuppressLint("StaticFieldLeak", "MissingPermission") +object NotificationTriggerController { + + const val CHANNEL_ID_RESIDENT = "resident_notification" + const val CHANNEL_ID_GENERAL = "general_notification" + const val CHANNEL_ID_GENERAL_SILENT = "general_silent_notification" + const val CHANNEL_NAME_RESIDENT = "recovery_resident" + const val CHANNEL_NAME_GENERAL = "recovery_single" + const val CHANNEL_NAME_GENERAL_SILENT = "recovery_loop" + + private var notificationManager: NotificationManagerCompat? = null + private var context: Context? = null + + // 全局协程作用域,用于异步构建通知 + private val notificationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + // 重复通知相关(全局单例) + private var repeatHandler: Handler? = null + private var repeatRunnable: Runnable? = null + private var repeatCount: Int = 0 + private var currentNotificationId: Int = 0 + + /** + * 初始化通知通道 + * @param context 上下文 + */ + fun initializeChannels(context: Context) { + this.context = context + this.notificationManager = NotificationManagerCompat.from(context) + + // 常驻通知通道 + val residentChannel = NotificationChannel( + CHANNEL_ID_RESIDENT, CHANNEL_NAME_RESIDENT, NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "for resident notification" + setShowBadge(false) + enableLights(false) + enableVibration(false) + } + + // 普通通知通道 + val generalChannel = NotificationChannel( + CHANNEL_ID_GENERAL, CHANNEL_NAME_GENERAL, NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "for general notification" + setShowBadge(true) + enableLights(false) + enableVibration(false) + } + + // 静音通知通道 + val silentChannel = NotificationChannel( + CHANNEL_ID_GENERAL_SILENT, + CHANNEL_NAME_GENERAL_SILENT, + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "for silent notification (repeat notifications)" + setShowBadge(true) + enableLights(false) + enableVibration(false) + setSound(null, null) // 设置为静音 + } + + notificationManager?.createNotificationChannels( + listOf( + residentChannel, generalChannel, silentChannel + ) + ) + NotiLogger.d("通知通道创建完成") + } + + + /** + * 通用通知触发方法 + * @param notificationType 通知类型 + * @param modelBuilder 通知数据构建函数 + * @param notificationBuilder 通知对象构建函数 + * @param onNotificationSent 通知发送完成回调 + */ + private fun triggerNotification( + notificationType: String, + modelBuilder: (Context) -> GeneralNotificationData, + notificationBuilder: (GeneralNotificationData) -> Notification, + onNotificationSent: ((GeneralNotificationData, Notification) -> Unit)? = null + ) { + val context = context ?: return + val notificationManager = notificationManager ?: return + + // 使用全局协程异步构建通知 + notificationScope.launch { + try { + // 在 IO 线程中构建通知数据 + val notificationData = withContext(Dispatchers.IO) { + modelBuilder(context) + } + + // 在 IO 线程中构建通知对象 + val notification = withContext(Dispatchers.IO) { + notificationBuilder(notificationData) + } + + // 切换到主线程执行 notify + withContext(Dispatchers.Main) { + notificationManager.notify(notificationData.notificationId, notification) + NotiLogger.d("${notificationType}发送成功,ID: ${notificationData.notificationId}") + + // 调用通知完成回调 + onNotificationSent?.invoke(notificationData, notification) + } + + } catch (e: Exception) { + NotiLogger.e("发送${notificationType}失败", e) + } + } + } + + /** + * 检查是否应该启用重复通知 + * @return 是否启用重复通知 + */ + private fun shouldEnableRepeatNotification(): Boolean { + val configController = NotificationConfigController + + // 检查是否开启重复通知策略 + if (configController.getHoverDurationStrategySwitch() != 1) { + return false + } + + val loopCount = configController.getHoverDurationLoopCount() + if (loopCount <= 0) { + return false + } + + return true + } + + /** + * 获取重复通知的循环次数 + * @return 循环次数 + */ + private fun getRepeatLoopCount(): Int { + return NotificationConfigController.getHoverDurationLoopCount() + } + + /** + * 检查并启动重复通知 + * @param notificationId 通知ID + * @param notification 通知对象 + */ + private fun checkAndStartRepeatNotification(notificationData: GeneralNotificationData, + notification: Notification, + type: NotificationCheckController.NotificationType) { + // 检查是否应该启用重复通知 + if (!shouldEnableRepeatNotification()) { + return + } + + val loopCount = getRepeatLoopCount() + + // 停止之前的重复任务 + stopRepeatNotification() + + NotiLogger.d("开始重复通知,ID: ${notificationData.notificationId},循环次数: $loopCount") + + // 初始化重复计数 + repeatCount = 0 + currentNotificationId = notificationData.notificationId + + // 创建 Handler + repeatHandler = Handler(Looper.getMainLooper()) + + // 创建重复任务 + repeatRunnable = object : Runnable { + override fun run() { + val maxCount = getRepeatLoopCount() + + if (repeatCount < maxCount) { + // 执行重复通知 + notificationManager?.notify(currentNotificationId, notification) + NotiLogger.d("重复通知执行,ID: $currentNotificationId,第${repeatCount + 1}次") + + // 增加计数 + repeatCount++ + + // 埋点 + generalTrack(type, notificationData) + + // 4秒后再次执行 + repeatHandler?.postDelayed(this, 4000) + } else { + // 达到最大次数,停止重复 + stopRepeatNotification() + NotiLogger.d("重复通知完成,ID: $currentNotificationId,总次数: $maxCount") + } + } + } + + // 4秒后开始第一次重复 + repeatHandler?.postDelayed(repeatRunnable!!, 4000) + } + + /** + * 停止重复通知 + */ + fun stopRepeatNotification() { + repeatHandler?.removeCallbacks(repeatRunnable ?: return) + repeatHandler = null + repeatRunnable = null + repeatCount = 0 + currentNotificationId = 0 + + NotiLogger.d("停止重复通知") + } + + fun triggerResidentNotification() { + if (context?.canSendNotification() == true) { + triggerNotification( + notificationType = "常驻通知", + modelBuilder = { context -> ResidentModelManger().getModel(context) }, + notificationBuilder = { data -> resident(data) }, + onNotificationSent = { notificationData, notification -> + residentTrack(notificationData) + }) + } + } + + fun triggerGeneralNotification( + type: NotificationCheckController.NotificationType, onNotificationSent: (() -> Unit)? = null + ) { + if (context?.canSendNotification() == true) { + triggerNotification( + notificationType = "常规通知", + modelBuilder = { context -> GeneralModelManager().getModel(context, type) }, + notificationBuilder = { data -> general(data) }, + onNotificationSent = { notificationData, notification -> + onNotificationSent?.invoke() + generalTrack(type, notificationData) + // 检查是否需要重复通知 + checkAndStartRepeatNotification(notificationData, notification,type) + }) + } + } + + private fun resident(model: GeneralNotificationData) = + NotificationCompat.Builder(context!!, CHANNEL_ID_RESIDENT) + .setColor(ContextCompat.getColor(context!!, R.color.noti_color)) + .setSmallIcon(R.drawable.ic_noti_icon) + .setContentTitle(model.contentTitle) + .setContentText(model.contentContent) + .setGroup(CHANNEL_ID_RESIDENT + model.notificationId) + .setContentIntent(model.contentIntent).setCustomContentView(model.contentView) + .setCustomBigContentView(model.contentView).setOngoing(true).setSilent(true).build() + + private fun general(model: GeneralNotificationData): Notification { + // 根据重复通知配置选择通道 + val channelId = if (shouldEnableRepeatNotification()) { + CHANNEL_ID_GENERAL_SILENT // 使用静音通道 + } else { + CHANNEL_ID_GENERAL // 使用普通通道 + } + + // 创建删除监听 Intent + val deleteIntent = Intent(context, NotificationDeleteReceiver::class.java).apply { + action = "com.remax.notification.ACTION_NOTIFICATION_DELETE" + } + val deletePendingIntent = PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context!!, channelId) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setColor(ContextCompat.getColor(context!!, R.color.noti_color)) + .setSmallIcon(R.drawable.ic_noti_icon).setAutoCancel(true) + .setGroup("push_" + System.currentTimeMillis()).setContentText(model.contentTitle) + .setContentIntent(model.contentIntent).setDeleteIntent(deletePendingIntent) // 设置删除监听 + .setCustomContentView(model.contentView).setCustomBigContentView(model.bigContentView) + + // 记录使用的通道 + if (shouldEnableRepeatNotification()) { + NotiLogger.d("使用静音通道发送通知: $channelId") + } else { + NotiLogger.d("使用普通通道发送通知: $channelId") + } + + return builder.apply { + if (Build.VERSION.SDK_INT >= 31) { + setCustomHeadsUpContentView(model.contentView) + } + }.build() + } + + /** + * 取消通知 + * @param notificationId 通知ID + */ + fun cancelNotification(notificationId: Int) { + // 停止重复通知 + stopRepeatNotification() + + // 取消通知 + notificationManager?.cancel(notificationId) + NotiLogger.d("通知已取消,ID: $notificationId") + } + + /** + * 取消所有通知 + */ + fun cancelAllNotifications() { + // 停止重复通知 + stopRepeatNotification() + + // 取消所有通知 + notificationManager?.cancelAll() + NotiLogger.d("所有通知已取消") + } + + /** + * 检查并确保常驻通知存在 + * 如果通知中心不存在常驻通知,则触发常驻通知 + */ + @SuppressLint("MissingPermission") + fun ensureResidentNotificationExists() { + val notificationManager = notificationManager ?: return + val residentNotificationId = type2notificationId[NotificationType.RESIDENT] ?: 0 + + // 使用协程异步执行 + notificationScope.launch { + try { + // 检查通知中心是否存在指定ID的通知 + val activeNotifications = notificationManager.activeNotifications + val hasResidentNotification = + activeNotifications.any { it.id == residentNotificationId } + + if (!hasResidentNotification) { + NotiLogger.d("通知中心不存在常驻通知(ID: $residentNotificationId),尝试触发常驻通知") + triggerResidentNotification() + } else { + NotiLogger.d("通知中心已存在常驻通知(ID: $residentNotificationId)") + } + } catch (e: Exception) { + NotiLogger.e("检查常驻通知状态失败", e) + // 检查失败时,为了安全起见,触发常驻通知 + triggerResidentNotification() + } + } + } + + + /** + * 获取常驻通知的ID + * @return 常驻通知ID + */ + fun getResidentNotificationId(): Int = type2notificationId[NotificationType.RESIDENT] ?: 0 + + /** + * 构建常驻通知 + * @param context 上下文 + * @return 常驻通知对象 + */ + fun buildResidentNotification(context: Context): Notification { + val residentModel = ResidentModelManger().getModel(context) + if(context.canSendNotification()){ + residentTrack(residentModel) + } + return resident(residentModel) + } + + private fun residentTrack(residentModel: GeneralNotificationData) { + DataReportManager.reportData( + "Notific_Show", mapOf( + "Notific_Type" to 4, + "Notific_Position" to 2, + "Notific_Priority" to "PRIORITY_DEFAULT", + "event_id" to "permanent", + "title" to residentModel.contentTitle, + "text" to residentModel.contentContent, + ) + ) + } + + private fun generalTrack( + type: NotificationCheckController.NotificationType, + notificationData: GeneralNotificationData + ) { + DataReportManager.reportData( + "Notific_Show", mapOf( + "Notific_Type" to when (type) { + NotificationCheckController.NotificationType.UNLOCK -> 1 + NotificationCheckController.NotificationType.BACKGROUND -> 1 + NotificationCheckController.NotificationType.KEEPALIVE -> 1 + NotificationCheckController.NotificationType.FCM -> 3 + else -> 4 + }, + "Notific_Position" to 1, + "Notific_Priority" to "PRIORITY_MAX", + "event_id" to "customer_general_style", + "title" to notificationData.contentTitle, + "text" to notificationData.contentContent, + ) + ) + } +} diff --git a/notification/src/main/java/com/remax/notification/provider/NotificationProvider.kt b/notification/src/main/java/com/remax/notification/provider/NotificationProvider.kt new file mode 100644 index 0000000..cfaa844 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/provider/NotificationProvider.kt @@ -0,0 +1,156 @@ +package com.remax.notification.provider + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import com.remax.notification.config.NotificationConfigController +import com.remax.notification.config.PushContentController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.timing.NotificationTimingController +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.service.NotificationKeepAliveService +import com.remax.notification.service.NotificationKeepAliveServiceManager +import com.remax.notification.utils.ResetAtMidnightController +import com.remax.notification.utils.NotiLogger + +/** + * 通知模块内容提供者 + * 用于获取 Context 并初始化配置控制器 + */ +class NotificationProvider : ContentProvider() { + + override fun onCreate(): Boolean { + NotiLogger.d("NotificationProvider onCreate") + + // 初始化0点重置控制器 + initializeResetController() + + // 初始化配置控制器 + initializeConfigControllers() + + // 初始化通知通道 + initializeNotificationChannels() + + // 初始化通知时机控制器 + initializeTimingController() + + // 初始化通知检查控制器 + initializeCheckController() + + return true + } + + /** + * 初始化0点重置控制器 + */ + private fun initializeResetController() { + try { + ResetAtMidnightController.getInstance().initialize(context!!) + NotiLogger.d("0点重置控制器初始化成功") + } catch (e: Exception) { + NotiLogger.e("初始化0点重置控制器时发生异常", e) + } + } + + /** + * 初始化配置控制器 + */ + private fun initializeConfigControllers() { + try { + // 初始化推送配置控制器 + val configSuccess = NotificationConfigController.initialize(context!!) + if (configSuccess) { + NotiLogger.d("推送配置控制器初始化成功") + } else { + NotiLogger.e("推送配置控制器初始化失败") + } + + // 初始化推送内容控制器 + val contentSuccess = PushContentController.initialize(context!!) + if (contentSuccess) { + NotiLogger.d("推送内容控制器初始化成功") + } else { + NotiLogger.e("推送内容控制器初始化失败") + } + + } catch (e: Exception) { + NotiLogger.e("初始化配置控制器时发生异常", e) + } + } + + /** + * 初始化通知通道 + */ + private fun initializeNotificationChannels() { + try { + NotificationTriggerController.initializeChannels(context!!) + NotiLogger.d("通知通道初始化成功") + } catch (e: Exception) { + NotiLogger.e("初始化通知通道时发生异常", e) + } + } + + /** + * 初始化通知时机控制器 + */ + private fun initializeTimingController() { + try { + NotificationTimingController.getInstance().initialize(context!!) + NotiLogger.d("通知时机控制器初始化成功") + } catch (e: Exception) { + NotiLogger.e("初始化通知时机控制器时发生异常", e) + } + } + + /** + * 初始化通知检查控制器 + */ + private fun initializeCheckController() { + try { + NotificationCheckController.getInstance().initialize(context!!) + NotiLogger.d("通知检查控制器初始化成功") + } catch (e: Exception) { + NotiLogger.e("初始化通知检查控制器时发生异常", e) + } + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + return null + } + + override fun getType(uri: Uri): String? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + return 0 + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int { + return 0 + } + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { + context?.let { + NotificationKeepAliveService.startService(it) + } + return super.call(method, arg, extras) + } +} diff --git a/notification/src/main/java/com/remax/notification/receiver/NotificationDeleteReceiver.kt b/notification/src/main/java/com/remax/notification/receiver/NotificationDeleteReceiver.kt new file mode 100644 index 0000000..1e7f9c7 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/receiver/NotificationDeleteReceiver.kt @@ -0,0 +1,25 @@ +package com.remax.notification.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.utils.NotiLogger + +/** + * 通知删除监听器 + * 用于监听通知的删除事件(滑动删除) + */ +class NotificationDeleteReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + "com.remax.notification.ACTION_NOTIFICATION_DELETE" -> { + NotiLogger.d("通知被删除(滑动删除),停止重复通知") + + // 停止重复通知 + NotificationTriggerController.stopRepeatNotification() + } + } + } +} diff --git a/notification/src/main/java/com/remax/notification/service/FCMService.kt b/notification/src/main/java/com/remax/notification/service/FCMService.kt new file mode 100644 index 0000000..80ac23e --- /dev/null +++ b/notification/src/main/java/com/remax/notification/service/FCMService.kt @@ -0,0 +1,115 @@ +package com.remax.notification.service + +import android.content.pm.PackageManager +import com.blankj.utilcode.util.AppUtils +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.remax.base.report.DataReportManager +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.timing.NotificationTimingController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.utils.Topic +import java.util.concurrent.atomic.AtomicLong + +/** + * FCM 消息处理服务 + * 处理推送通知的接收和显示 + */ +class FCMService : FirebaseMessagingService() { + + companion object { + private const val TAG = "FCMService" + private const val VERSION_KEY = "version" // FCM消息中的version字段 + + /** + * 初始化 FCM 服务 + * 这个方法可以在应用启动时调用,确保 FCM 服务被注册 + */ + fun initialize() { + // FCM 服务会在收到消息时自动创建,这里只是记录初始化状态 + NotiLogger.d("FCM 服务已准备就绪,等待消息") + } + + /** + * 检查version字段是否匹配当前应用版本 + * @param messageVersion FCM消息中的version字段值 + * @param currentVersion 当前应用版本 + * @return true表示匹配或无需检查,false表示不匹配 + */ + private fun isVersionMatched(messageVersion: String?, currentVersion: String): Boolean { + // version没有值的时候不判断,全量发送 + if (messageVersion.isNullOrBlank()) { + NotiLogger.d("FCM消息无version字段,全量发送") + return true + } + + // 有值的时候,客户端需要判断=当前值才发送 + val isMatched = messageVersion == currentVersion + NotiLogger.d("FCM消息version检查: 消息version=$messageVersion, 当前version=$currentVersion, 匹配结果=$isMatched") + return isMatched + } + } + + override fun onCreate() { + super.onCreate() + } + + /** + * 当收到 FCM 消息时调用 + */ + override fun onMessageReceived(remoteMessage: RemoteMessage) { + + DataReportManager.reportData("Notific_Pull", mapOf("topic" to Topic.ALL)) + + NotiLogger.d("收到 FCM 消息") + NotiLogger.d("消息来源: ${remoteMessage.from}") + NotiLogger.d("消息 ID: ${remoteMessage.messageId}") + NotiLogger.d("消息类型: ${remoteMessage.messageType}") + + // 处理数据载荷 + if (remoteMessage.data.isNotEmpty()) { + NotiLogger.d("消息数据载荷:") + for ((key, value) in remoteMessage.data) { + NotiLogger.d(" $key: $value") + } + } + + // 检查version字段 + val messageVersion = remoteMessage.data[VERSION_KEY] + val currentVersion = AppUtils.getAppVersionName() + +// NotiLogger.d("FCM消息version检查开始") + NotiLogger.d("当前应用版本: $currentVersion") + NotiLogger.d("消息version字段: $messageVersion") + + // 根据version字段决定是否处理消息 +// if (!isVersionMatched(messageVersion, currentVersion)) { +// NotiLogger.d("FCM消息version不匹配,忽略此消息") +// return +// } + +// NotiLogger.d("FCM消息version检查通过,继续处理消息") + + // 处理真正的操作 + triggerFCMNotification() + } + + private fun triggerFCMNotification() { + NotificationKeepAliveServiceManager.startKeepAliveService(context = this, from = "fcm") + NotificationTimingController.getInstance().triggerNotificationIfAllowed( + NotificationCheckController.NotificationType.FCM + ) + } + + /** + * 当 FCM 令牌更新时调用 + */ + override fun onNewToken(token: String) { + super.onNewToken(token) + NotiLogger.d("FCM 令牌已更新: $token") + + } + + +} diff --git a/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveService.kt b/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveService.kt new file mode 100644 index 0000000..8457f34 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveService.kt @@ -0,0 +1,284 @@ +package com.remax.notification.service + +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import com.remax.base.ext.KvLongDelegate +import com.remax.base.ext.canSendNotification +import com.remax.base.report.DataReportManager +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.timing.NotificationTimingController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.utils.Topic + +/** + * 前台保活服务 + * 用于定期触发通知保活机制 + */ +class NotificationKeepAliveService : Service() { + + companion object { + private val NOTIFICATION_ID = NotificationTriggerController.getResidentNotificationId() + + // 默认15分钟 = 900秒 + private const val DEFAULT_INTERVAL_SECONDS = 900L + + // 持久化存储默认间隔时间 + var defaultIntervalSeconds by KvLongDelegate("notification_keep_alive_default_interval", DEFAULT_INTERVAL_SECONDS) + + // 服务控制参数 + private const val ACTION_START_SERVICE = "com.remax.notification.START_KEEP_ALIVE_SERVICE" + private const val ACTION_STOP_SERVICE = "com.remax.notification.STOP_KEEP_ALIVE_SERVICE" + private const val ACTION_UPDATE_NOTIFICATION = "com.remax.notification.UPDATE_FOREGROUND_NOTIFICATION" + private const val EXTRA_INTERVAL_SECONDS = "interval_seconds" + + private var isServiceRunning = false + + /** + * 设置默认间隔时间 + * @param seconds 间隔时间(秒) + */ + fun setDefaultIntervalSeconds1(seconds: Long) { + defaultIntervalSeconds = seconds + NotiLogger.d("设置通知保活默认轮训间隔时间: ${seconds}秒") + } + + /** + * 启动保活服务 + * @param context 上下文 + * @param intervalSeconds 间隔时间(秒),默认使用持久化存储的值 + */ + fun startService(context: Context, intervalSeconds: Long = defaultIntervalSeconds) { + val intent = Intent(context, NotificationKeepAliveService::class.java).apply { + action = ACTION_START_SERVICE + putExtra(EXTRA_INTERVAL_SECONDS, intervalSeconds) + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + NotiLogger.d("启动保活服务,间隔: ${intervalSeconds}秒") + } catch (e: Exception) { + DataReportManager.reportData("Notific_Show_Fail",mapOf("reason" to "alive_service_${e.message}")) + NotiLogger.e("启动保活服务失败", e) + } + } + + /** + * 停止保活服务 + * @param context 上下文 + */ + fun stopService(context: Context) { + val intent = Intent(context, NotificationKeepAliveService::class.java).apply { + action = ACTION_STOP_SERVICE + } + + try { + context.startService(intent) + NotiLogger.d("停止保活服务") + } catch (e: Exception) { + NotiLogger.e("停止保活服务失败", e) + } + } + + + /** + * 更新前台服务通知 + * @param context 上下文 + */ + fun updateNotification(context: Context) { + val intent = Intent(context, NotificationKeepAliveService::class.java).apply { + action = ACTION_UPDATE_NOTIFICATION + } + + try { + context.startService(intent) + NotiLogger.d("前台服务通知更新请求已发送") + } catch (e: Exception) { + NotiLogger.e("更新前台服务通知失败", e) + } + } + } + + private var handler: Handler? = null + private var keepAliveRunnable: Runnable? = null + private var intervalSeconds: Long = defaultIntervalSeconds + + override fun onCreate() { + super.onCreate() + NotiLogger.d("保活服务创建") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START_SERVICE -> { + intervalSeconds = + intent.getLongExtra(EXTRA_INTERVAL_SECONDS, defaultIntervalSeconds) + startForegroundService() + } + + ACTION_STOP_SERVICE -> { + stopForegroundService() + } + + ACTION_UPDATE_NOTIFICATION -> { + updateForegroundNotification() + } + } + return START_STICKY // 服务被杀死后自动重启 + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + stopForegroundService() + NotiLogger.d("保活服务销毁") + } + + /** + * 启动前台服务 + */ + private fun startForegroundService() { + if (isServiceRunning) { + NotiLogger.d("保活服务已在运行中,刷新通知,间隔: ${intervalSeconds}秒") + // 服务已运行,只刷新通知 + updateForegroundNotification() + return + } + + // 检查通知权限 + val hasNotificationPermission = canSendNotification() + NotiLogger.d("通知权限状态: $hasNotificationPermission") + + if (!hasNotificationPermission) { + NotiLogger.w("没有通知权限,前台服务通知可能不会显示") + } + + isServiceRunning = true + + // 创建前台通知 + val notification = createForegroundNotification() + + // 启动前台服务 + startForeground(NOTIFICATION_ID, notification) + + // 启动定时任务 + startKeepAliveTask() + + NotiLogger.d("保活服务启动成功,间隔: ${intervalSeconds}秒") + } + + /** + * 停止前台服务 + */ + private fun stopForegroundService() { + if (!isServiceRunning) { + return + } + + isServiceRunning = false + + // 停止定时任务 + stopKeepAliveTask() + + // 停止前台服务 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + + NotiLogger.d("保活服务停止") + } + + /** + * 启动保活任务 + */ + private fun startKeepAliveTask() { + handler = Handler(Looper.getMainLooper()) + + keepAliveRunnable = object : Runnable { + override fun run() { + if (!isServiceRunning) { + return + } + + try { + NotiLogger.d("执行保活任务") + DataReportManager.reportData("Notific_Pull", mapOf("topic" to "timer")) + updateForegroundNotification() + // 尝试触发保活通知 + NotificationTimingController.getInstance().triggerNotificationIfAllowed( + NotificationCheckController.NotificationType.KEEPALIVE + ) + + NotiLogger.d("保活任务执行完成,下次间隔: ${intervalSeconds}秒") + + } catch (e: Exception) { + NotiLogger.e("保活任务执行失败", e) + } + + // 调度下次执行 + handler?.postDelayed(this, intervalSeconds * 1000) + } + } + + // 延迟执行首次任务 + handler?.postDelayed(keepAliveRunnable!!, intervalSeconds * 1000) + } + + /** + * 停止保活任务 + */ + private fun stopKeepAliveTask() { + keepAliveRunnable?.let { runnable -> + handler?.removeCallbacks(runnable) + } + keepAliveRunnable = null + handler = null + } + + /** + * 更新前台服务通知 + */ + private fun updateForegroundNotification() { + if (!isServiceRunning) { + NotiLogger.d("前台服务未运行,无法更新通知") + return + } + + if (!canSendNotification()) { + NotiLogger.d("无通知权限,忽略本次更新通知") + return + } + + try { + val newNotification = createForegroundNotification() + startForeground(NOTIFICATION_ID, newNotification) + NotiLogger.d("前台服务通知已更新") + } catch (e: Exception) { + NotiLogger.e("更新前台服务通知失败", e) + } + } + + /** + * 创建前台通知 + */ + private fun createForegroundNotification(): Notification { + // 使用NotificationTriggerController提供的构建函数 + return NotificationTriggerController.buildResidentNotification(this) + } + +} \ No newline at end of file diff --git a/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveServiceManager.kt b/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveServiceManager.kt new file mode 100644 index 0000000..3b11197 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/service/NotificationKeepAliveServiceManager.kt @@ -0,0 +1,88 @@ +package com.remax.notification.service + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.net.toUri +import com.blankj.utilcode.util.ServiceUtils +import com.remax.base.ext.canSendNotification +import com.remax.base.report.DataReportManager +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.utils.NotiLogger + +/** + * 前台保活服务管理器 + * 提供便捷的服务控制接口 + */ +object NotificationKeepAliveServiceManager { + + private const val TAG = "NotificationKeepAliveServiceManager" + + /** + * 启动保活服务 + * @param context 上下文 + * @param intervalSeconds 间隔时间(秒),默认使用持久化存储的值 + */ + fun startKeepAliveService(context: Context,from: String = "localPush" ) { + try { + DataReportManager.reportData("Notific_Pull", mapOf("topic" to "permanent")) + if(!context.canSendNotification()){ + DataReportManager.reportData("Notific_Show_Fail",mapOf("reason" to "alive_service_${from}_no_permission")) + NotiLogger.d("无通知权限,前台服务忽略启动") + return + } + if (isKeepAliveServiceRunning()) { + // 服务已运行,更新通知栏 + NotiLogger.d("保活服务已在运行中,刷新通知栏") + NotificationKeepAliveService.updateNotification(context) + } else { + // 服务未运行,启动服务 + try { + NotificationKeepAliveService.startService(context) + NotiLogger.d("保活服务启动请求已发送") + } + catch (e: Exception){ + NotiLogger.e("启动保活服务失败,尝试使用contentResolver方式启动服务", e) + } + startWithCall(context,from) + } + } catch (e: Exception) { + NotificationTriggerController.ensureResidentNotificationExists() + DataReportManager.reportData("Notific_Show_Fail",mapOf("reason" to "alive_service_${from}_${e.message}")) + NotiLogger.e("启动保活服务失败", e) + } + } + + private fun startWithCall(context: Context,from: String ) { + Handler(Looper.getMainLooper()).postDelayed({ + try { + if (!isKeepAliveServiceRunning()) { + val contentResolver = context.contentResolver + contentResolver.call( + "content://${context.packageName}.notification.provider".toUri(), + Intent( + context, + NotificationKeepAliveService::class.java + ).component?.className ?: "", + "", + null + ) + } + } catch (e: Exception) { + e.printStackTrace() + DataReportManager.reportData("Notific_Show_Fail",mapOf("reason" to "alive_service_${from}_${e.message}")) + } + }, 1000) + } + + /** + * 检查保活服务是否在运行 + * @return 是否在运行 + */ + fun isKeepAliveServiceRunning(): Boolean { + return ServiceUtils.isServiceRunning(NotificationKeepAliveService::class.java) + } +} diff --git a/notification/src/main/java/com/remax/notification/timing/NotificationTimingController.kt b/notification/src/main/java/com/remax/notification/timing/NotificationTimingController.kt new file mode 100644 index 0000000..ff4ac91 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/timing/NotificationTimingController.kt @@ -0,0 +1,304 @@ +package com.remax.notification.timing + +import android.app.ActivityManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.blankj.utilcode.util.Utils +import com.remax.base.report.DataReportManager +import com.remax.notification.config.NotificationConfigController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.utils.FCMTopicManager +import com.remax.notification.service.FCMService +import com.remax.notification.service.NotificationKeepAliveServiceManager +import com.remax.notification.utils.Topic +import com.remax.notification.worker.NotificationKeepAliveWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 通知时机控制器 + * 监听锁屏和app切到后台事件,在合适的时机触发通知 + */ +class NotificationTimingController private constructor() : LifecycleObserver { + + companion object { + private const val TAG = "NotificationTimingController" + + @Volatile + private var INSTANCE: NotificationTimingController? = null + + fun getInstance(): NotificationTimingController { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: NotificationTimingController().also { INSTANCE = it } + } + } + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val isInitialized = AtomicBoolean(false) + private val isAppInForeground = AtomicBoolean(false) // 添加前台状态标识位 + private var context: Context? = null + private var screenReceiver: ScreenReceiver? = null + + /** + * 初始化通知时机控制器 + * @param context 上下文 + */ + fun initialize(context: Context) { + if (isInitialized.getAndSet(true)) { + NotiLogger.d("通知时机控制器已经初始化") + return + } + + this.context = context.applicationContext + NotiLogger.d("通知时机控制器初始化开始") + + // 注册锁屏监听 + registerScreenReceiver() + + // 注册应用生命周期监听 + registerAppLifecycleObserver() + + // 启动 WorkManager 保活 + startWorkManagerKeepAlive() + + // 订阅 FCM 主题 + subscribeFCMTopics() + + // 初始化 FCM 服务 + FCMService.initialize() + + // 获取并记录 FCM 令牌 + FCMTopicManager.getFCMToken { token -> + if (token != null) { + NotiLogger.d("FCM 令牌获取成功,长度: ${token.length}") + } else { + NotiLogger.w("FCM 令牌获取失败") + } + } + + // 记录 FCM 订阅状态 + FCMTopicManager.logSubscriptionStatus() + + NotiLogger.d("通知时机控制器初始化完成") + } + + /** + * 订阅 FCM 主题 + */ + private fun subscribeFCMTopics() { + try { + NotiLogger.d("开始订阅 FCM 主题") + FCMTopicManager.subscribeCommonTopic() + NotiLogger.d("FCM 主题订阅完成") + } catch (e: Exception) { + NotiLogger.e("订阅 FCM 主题失败", e) + } + } + + /** + * 启动 WorkManager 保活 + */ + private fun startWorkManagerKeepAlive() { + try { + // 延迟初始化 WorkManager,确保应用完全启动 + Handler(Looper.getMainLooper()).postDelayed({ + try { + val workManager = WorkManager.getInstance(context!!) + + // 取消现有的周期性保活工作 + workManager.cancelUniqueWork("notification_keep_alive") + + // 创建周期性保活工作(每15分钟执行一次) + val periodicWorkRequest = PeriodicWorkRequestBuilder( + 15, TimeUnit.MINUTES + ).setInputData(workDataOf("type" to "periodic_keep_alive")) + .build() + + // 提交周期性工作请求 + workManager.enqueueUniquePeriodicWork( + "notification_keep_alive", + androidx.work.ExistingPeriodicWorkPolicy.REPLACE, + periodicWorkRequest + ) + + NotiLogger.d("WorkManager 周期性保活已启动(每15分钟执行一次)") + } catch (e: Exception) { + NotiLogger.e("启动 WorkManager 保活失败", e) + } + }, 1000) // 延迟1秒执行 + } catch (e: Exception) { + NotiLogger.e("启动 WorkManager 保活失败", e) + } + } + + /** + * 注册锁屏广播接收器 + */ + private fun registerScreenReceiver() { + try { + screenReceiver = ScreenReceiver() + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_USER_PRESENT) + } + context?.registerReceiver(screenReceiver, filter) + NotiLogger.d("锁屏监听器注册成功") + } catch (e: Exception) { + NotiLogger.e("注册锁屏监听器失败", e) + } + } + + /** + * 注册应用生命周期观察者 + */ + private fun registerAppLifecycleObserver() { + try { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + NotiLogger.d("应用生命周期监听器注册成功") + } catch (e: Exception) { + NotiLogger.e("注册应用生命周期监听器失败", e) + } + } + + /** + * 应用切到后台 + */ + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + fun onAppBackground() { + isAppInForeground.set(false) + NotiLogger.d("应用切到后台") + triggerNotificationIfAllowed(NotificationCheckController.NotificationType.BACKGROUND) + } + + /** + * 应用切到前台 + */ + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun onAppForeground() { + isAppInForeground.set(true) + NotiLogger.d("应用切到前台") + NotificationTriggerController.stopRepeatNotification() + Utils.getApp().let { + NotificationKeepAliveServiceManager.startKeepAliveService(it) + } + + } + + /** + * 检查应用是否在前台 + * @return 是否在前台 + */ + fun isAppInForeground(): Boolean { + return isAppInForeground.get() + } + + /** + * 锁屏广播接收器 + */ + inner class ScreenReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_OFF -> { + NotiLogger.d("屏幕关闭") + NotificationTriggerController.stopRepeatNotification() + } + Intent.ACTION_SCREEN_ON -> { + NotiLogger.d("屏幕点亮") + context?.let { + NotificationKeepAliveServiceManager.startKeepAliveService(context) + } + } + Intent.ACTION_USER_PRESENT -> { + handleUnlock() + } + } + } + } + + /** + * 处理解锁事件 + */ + private fun handleUnlock() { + NotiLogger.d("用户解锁屏幕") + triggerNotificationIfAllowed(NotificationCheckController.NotificationType.UNLOCK) + } + + /** + * 通用通知触发方法 + * @param type 通知类型 + */ + fun triggerNotificationIfAllowed(type: NotificationCheckController.NotificationType) { + val description = when (type) { + NotificationCheckController.NotificationType.UNLOCK -> "解锁通知" + NotificationCheckController.NotificationType.BACKGROUND -> "后台通知" + NotificationCheckController.NotificationType.KEEPALIVE -> "保活通知" + NotificationCheckController.NotificationType.FCM -> "FCM推送通知" + NotificationCheckController.NotificationType.RESIDENT -> "" + } + DataReportManager.reportData("Notific_Pull", mapOf("topic" to "localPush")) + + // 检查是否可以触发通知,并获取具体的拦截原因 + val checkResult = NotificationCheckController.getInstance().canTriggerNotificationWithReason(type) + if (!checkResult.first) { + val blockReason = checkResult.second + val reasonString = blockReason?.reason ?: "unknown" + val reasonDescription = blockReason?.description ?: "未知原因" + + NotiLogger.d("${description}检查未通过,跳过触发 - 原因: ${reasonDescription}") + DataReportManager.reportData("Notific_Show_Fail", mapOf( + "reason" to "app_inner_${type.string}_${reasonString}", + )) + return + } + + NotiLogger.d("触发${description}") + NotificationTriggerController.triggerGeneralNotification(type){ + NotificationCheckController.getInstance().recordNotificationTrigger(type) + NotificationCheckController.getInstance().incrementNotificationCount() + } + } + + /** + * 释放资源 + */ + fun release() { + try { + screenReceiver?.let { receiver -> + context?.unregisterReceiver(receiver) + screenReceiver = null + } + + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + + isInitialized.set(false) + context = null + + NotiLogger.d("通知时机控制器已释放") + } catch (e: Exception) { + NotiLogger.e("释放通知时机控制器失败", e) + } + } + +} diff --git a/notification/src/main/java/com/remax/notification/utils/DateUtil.kt b/notification/src/main/java/com/remax/notification/utils/DateUtil.kt new file mode 100644 index 0000000..db1dddf --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/DateUtil.kt @@ -0,0 +1,36 @@ +package com.remax.notification.utils + +import android.os.Build +import android.util.Log +import java.util.* + +/** + * 日期时间工具类 + */ +object DateUtil { + + /** + * 获取时区偏移小时数 + * @return 时区偏移小时数 + */ + fun getTimeZoneOffsetHours(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val offsetSeconds = java.time.ZoneId.systemDefault().rules + .getOffset(java.time.Instant.now()).totalSeconds + offsetSeconds / 3600 + } else { + val calendar = Calendar.getInstance() + val offsetMillis = TimeZone.getDefault().getOffset(calendar.timeInMillis) + offsetMillis / (1000 * 3600) + } + } + + /** + * 获取带时区的 Firebase 主题名称 + * @param topic 基础主题名称 + * @return 带时区的主题名称 + */ + fun getFirebaseTopicWithTimezone(topic: String): String { + return "${topic}_${getTimeZoneOffsetHours() + 24}" + } +} diff --git a/notification/src/main/java/com/remax/notification/utils/FCMTopicManager.kt b/notification/src/main/java/com/remax/notification/utils/FCMTopicManager.kt new file mode 100644 index 0000000..f3a6f6f --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/FCMTopicManager.kt @@ -0,0 +1,84 @@ +package com.remax.notification.utils + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging +import com.remax.notification.utils.DateUtil +import com.remax.notification.utils.Topic +import com.remax.notification.utils.NotiLogger + +/** + * FCM 主题订阅工具类 + */ +object FCMTopicManager { + private const val TAG = "FCMTopicManager" + + /** + * 订阅通用主题 + */ + fun subscribeCommonTopic() { + // 全量推送(推送给所有用户) + subscribeToTopic(Topic.ALL) + // 全量推送(推送给所有用户),指定时区定时 + subscribeToTopic(DateUtil.getFirebaseTopicWithTimezone(Topic.ALL)) + } + + /** + * 订阅指定主题 + * @param topic 主题名称 + */ + fun subscribeToTopic(topic: String) { + NotiLogger.d("订阅主题: $topic") + FirebaseMessaging.getInstance().subscribeToTopic(topic) + .addOnCompleteListener { task -> + var msg = "订阅成功" + if (!task.isSuccessful) { + msg = "订阅失败" + } + NotiLogger.d("主题订阅结果:[$topic] $msg") + } + } + + /** + * 取消订阅指定主题 + * @param topic 主题名称 + */ + fun unsubscribeFromTopic(topic: String) { + NotiLogger.d("取消订阅主题: $topic") + FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) + .addOnCompleteListener { task -> + var msg = "取消订阅成功" + if (!task.isSuccessful) { + msg = "取消订阅失败" + } + NotiLogger.d("主题取消订阅结果:[$topic] $msg") + } + } + + /** + * 记录订阅状态 + */ + fun logSubscriptionStatus() { + NotiLogger.d("=== FCM 主题订阅状态 ===") + NotiLogger.d("已订阅主题:") + NotiLogger.d("- ALL (全量推送)") + NotiLogger.d("- ${DateUtil.getFirebaseTopicWithTimezone(Topic.ALL)} (时区推送)") + NotiLogger.d("========================") + } + + /** + * 获取 FCM 令牌 + */ + fun getFCMToken(callback: (String?) -> Unit) { + FirebaseMessaging.getInstance().token + .addOnCompleteListener { task -> + if (task.isSuccessful) { + val token = task.result + NotiLogger.d("FCM Token: $token") + callback(token) + } else { + NotiLogger.e("获取 FCM Token 失败", task.exception) + callback(null) + } + } + } +} diff --git a/notification/src/main/java/com/remax/notification/utils/NotiLogger.kt b/notification/src/main/java/com/remax/notification/utils/NotiLogger.kt new file mode 100644 index 0000000..e8e3d54 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/NotiLogger.kt @@ -0,0 +1,159 @@ +package com.remax.notification.utils + +import android.util.Log +import com.remax.notification.BuildConfig + +/** + * 广告日志工具类 + * 提供统一的日志输出控制和管理 + */ +object NotiLogger { + private const val TAG = "NotificationModule" + + /** + * 日志开关,默认为true + */ + private var isLogEnabled = BuildConfig.DEBUG + + /** + * 设置日志开关 + * @param enabled 是否启用日志 + */ + fun setLogEnabled(enabled: Boolean) { + isLogEnabled = enabled + } + + /** + * 获取日志开关状态 + * @return 是否启用日志 + */ + fun isLogEnabled(): Boolean = isLogEnabled + + /** + * Debug日志 + * @param message 日志消息 + */ + fun d(message: String) { + if (isLogEnabled) { + Log.d(TAG, message) + } + } + + /** + * Debug日志(带参数) + * @param message 日志消息模板 + * @param args 参数列表 + */ + fun d(message: String, vararg args: Any?) { + if (isLogEnabled) { + Log.d(TAG, message.format(*args)) + } + } + + /** + * Warning日志 + * @param message 日志消息 + */ + fun w(message: String) { + if (isLogEnabled) { + Log.w(TAG, message) + } + } + + /** + * Warning日志(带参数) + * @param message 日志消息模板 + * @param args 参数列表 + */ + fun w(message: String, vararg args: Any?) { + if (isLogEnabled) { + Log.w(TAG, message.format(*args)) + } + } + + /** + * Error日志 + * @param message 日志消息 + */ + fun e(message: String) { + if (isLogEnabled) { + Log.e(TAG, message) + } + } + + /** + * Error日志(带异常) + * @param message 日志消息 + * @param throwable 异常对象 + */ + fun e(message: String, throwable: Throwable?) { + if (isLogEnabled) { + Log.e(TAG, message, throwable) + } + } + + /** + * Error日志(带参数) + * @param message 日志消息模板 + * @param args 参数列表 + */ + fun e(message: String, vararg args: Any?) { + if (isLogEnabled) { + Log.e(TAG, message.format(*args)) + } + } + + /** + * Error日志(带参数和异常) + * @param message 日志消息模板 + * @param throwable 异常对象 + * @param args 参数列表 + */ + fun e(message: String, throwable: Throwable?, vararg args: Any?) { + if (isLogEnabled) { + Log.e(TAG, message.format(*args), throwable) + } + } + + /** + * Info日志 + * @param message 日志消息 + */ + fun i(message: String) { + if (isLogEnabled) { + Log.i(TAG, message) + } + } + + /** + * Info日志(带参数) + * @param message 日志消息模板 + * @param args 参数列表 + */ + fun i(message: String, vararg args: Any?) { + if (isLogEnabled) { + Log.i(TAG, message.format(*args)) + } + } + + /** + * Verbose日志 + * @param message 日志消息 + */ + fun v(message: String) { + if (isLogEnabled) { + Log.v(TAG, message) + } + } + + /** + * Verbose日志(带参数) + * @param message 日志消息模板 + * @param args 参数列表 + */ + fun v(message: String, vararg args: Any?) { + if (isLogEnabled) { + Log.v(TAG, message.format(*args)) + } + } +} \ No newline at end of file diff --git a/notification/src/main/java/com/remax/notification/utils/NotificationRemoteViewsBuilder.kt b/notification/src/main/java/com/remax/notification/utils/NotificationRemoteViewsBuilder.kt new file mode 100644 index 0000000..77279b2 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/NotificationRemoteViewsBuilder.kt @@ -0,0 +1,68 @@ +package com.example.features.notification.controller + +import android.app.PendingIntent +import android.graphics.Bitmap +import android.net.Uri +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews + +class NotificationRemoteViewsBuilder( + private val packageName: String, + private val layoutIdAboveAndroid12: Int, + private val layoutIdBelowAndroid12: Int +) { + private var remoteViews: RemoteViews = RemoteViews( + packageName, if (android.os.Build.VERSION.SDK_INT >= 31) { + layoutIdAboveAndroid12 + } else { + layoutIdBelowAndroid12 + } + ) + + fun setViewVisible(viewId: Int, isVisible:Boolean): NotificationRemoteViewsBuilder { + remoteViews.setViewVisibility(viewId, if (isVisible) View.VISIBLE else View.GONE) + return this + } + + fun setTextViewText(viewId: Int, text: CharSequence): NotificationRemoteViewsBuilder { + remoteViews.setTextViewText(viewId, text) + return this + } + + fun setTextViewTextSize(viewId: Int, size: Float): NotificationRemoteViewsBuilder { + remoteViews.setTextViewTextSize(viewId, TypedValue.COMPLEX_UNIT_SP, size) + return this + } + + fun setImageViewResource(viewId: Int, resId: Int): NotificationRemoteViewsBuilder { + remoteViews.setImageViewResource(viewId, resId) + return this + } + + fun setImageViewBitmap(viewId: Int, bitmap: Bitmap): NotificationRemoteViewsBuilder { + remoteViews.setImageViewBitmap(viewId, bitmap) + return this + } + + fun setImageViewUri(viewId: Int, uri: Uri): NotificationRemoteViewsBuilder { + remoteViews.setImageViewUri(viewId, uri) + return this + } + + fun setOnClickPendingIntent(viewId: Int, pendingIntent: PendingIntent): NotificationRemoteViewsBuilder { + remoteViews.setOnClickPendingIntent(viewId, pendingIntent) + return this + } + + fun setProgressBar( + viewId: Int, max: Int, progress: Int, indeterminate: Boolean + ): NotificationRemoteViewsBuilder { + remoteViews.setProgressBar(viewId, max, progress, indeterminate) + return this + } + + fun build(): RemoteViews { + return remoteViews + } +} \ No newline at end of file diff --git a/notification/src/main/java/com/remax/notification/utils/ResetAtMidnightController.kt b/notification/src/main/java/com/remax/notification/utils/ResetAtMidnightController.kt new file mode 100644 index 0000000..72ca22f --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/ResetAtMidnightController.kt @@ -0,0 +1,193 @@ +package com.remax.notification.utils + +import android.content.Context +import android.content.SharedPreferences +import java.time.LocalDate +import java.util.concurrent.atomic.AtomicLong + +/** + * 0点重置控制器 + * 用于管理需要在0点重置的数据,支持持久化和自动重置 + */ +class ResetAtMidnightController private constructor() { + + companion object { + @Volatile + private var INSTANCE: ResetAtMidnightController? = null + + fun getInstance(): ResetAtMidnightController { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ResetAtMidnightController().also { INSTANCE = it } + } + } + } + + private var sharedPreferences: SharedPreferences? = null + private var context: Context? = null + + /** + * 初始化 + * @param context 上下文 + */ + fun initialize(context: Context) { + if (sharedPreferences != null) { + NotiLogger.d("ResetAtMidnightController 已经初始化") + return + } + + this.context = context.applicationContext + sharedPreferences = context.getSharedPreferences("reset_at_midnight_prefs", Context.MODE_PRIVATE) + NotiLogger.d("ResetAtMidnightController 初始化完成") + } + + /** + * 获取长整型值(带0点重置逻辑) + * @param key 键名 + * @param defaultValue 默认值 + * @param enableMidnightReset 是否启用0点重置 + * @return 当前值 + */ + fun getLongValue(key: String, defaultValue: Long = 0L, enableMidnightReset: Boolean = true): Long { + val prefs = sharedPreferences ?: return defaultValue + + if (enableMidnightReset) { + val today = LocalDate.now().toString() + val lastDateKey = "${key}_date" + val lastDate = prefs.getString(lastDateKey, "") + val lastValue = prefs.getLong(key, defaultValue) + + // 如果是新的一天,重置为默认值 + if (lastDate != today) { + prefs.edit() + .putString(lastDateKey, today) + .putLong(key, defaultValue) + .apply() + NotiLogger.d("新的一天,重置 $key: $defaultValue") + return defaultValue + } + + return lastValue + } else { + // 不启用0点重置,直接返回存储的值 + return prefs.getLong(key, defaultValue) + } + } + + /** + * 设置长整型值 + * @param key 键名 + * @param value 值 + * @param enableMidnightReset 是否启用0点重置 + */ + fun setLongValue(key: String, value: Long, enableMidnightReset: Boolean = true) { + val prefs = sharedPreferences ?: return + + if (enableMidnightReset) { + val today = LocalDate.now().toString() + val lastDateKey = "${key}_date" + + prefs.edit() + .putString(lastDateKey, today) + .putLong(key, value) + .apply() + + NotiLogger.d("设置 $key: $value (启用0点重置)") + } else { + prefs.edit() + .putLong(key, value) + .apply() + + NotiLogger.d("设置 $key: $value (不启用0点重置)") + } + } + + /** + * 获取整型值(带0点重置逻辑) + * @param key 键名 + * @param defaultValue 默认值 + * @param enableMidnightReset 是否启用0点重置 + * @return 当前值 + */ + fun getIntValue(key: String, defaultValue: Int = 0, enableMidnightReset: Boolean = true): Int { + return getLongValue(key, defaultValue.toLong(), enableMidnightReset).toInt() + } + + /** + * 设置整型值 + * @param key 键名 + * @param value 值 + * @param enableMidnightReset 是否启用0点重置 + */ + fun setIntValue(key: String, value: Int, enableMidnightReset: Boolean = true) { + setLongValue(key, value.toLong(), enableMidnightReset) + } + + /** + * 增加长整型值(原子操作) + * @param key 键名 + * @param increment 增量 + * @param enableMidnightReset 是否启用0点重置 + * @return 增加后的值 + */ + fun incrementLongValue(key: String, increment: Long = 1L, enableMidnightReset: Boolean = true): Long { + val currentValue = getLongValue(key, 0L, enableMidnightReset) + val newValue = currentValue + increment + setLongValue(key, newValue, enableMidnightReset) + return newValue + } + + /** + * 增加整型值(原子操作) + * @param key 键名 + * @param increment 增量 + * @param enableMidnightReset 是否启用0点重置 + * @return 增加后的值 + */ + fun incrementIntValue(key: String, increment: Int = 1, enableMidnightReset: Boolean = true): Int { + return incrementLongValue(key, increment.toLong(), enableMidnightReset).toInt() + } + + /** + * 重置指定键的值 + * @param key 键名 + * @param defaultValue 默认值 + * @param enableMidnightReset 是否启用0点重置 + */ + fun resetValue(key: String, defaultValue: Long = 0L, enableMidnightReset: Boolean = true) { + setLongValue(key, defaultValue, enableMidnightReset) + NotiLogger.d("手动重置 $key: $defaultValue (启用0点重置: $enableMidnightReset)") + } + + /** + * 检查是否已初始化 + * @return 是否已初始化 + */ + fun isInitialized(): Boolean { + return sharedPreferences != null + } + + /** + * 获取所有键的当前状态 + * @return 状态信息字符串 + */ + fun getStatus(): String { + val prefs = sharedPreferences ?: return "未初始化" + + val today = LocalDate.now().toString() + val allPrefs = prefs.all + + return buildString { + appendLine("ResetAtMidnightController 状态:") + appendLine("当前日期: $today") + appendLine("已存储的键:") + + allPrefs.forEach { (key, value) -> + if (!key.endsWith("_date")) { + val dateKey = "${key}_date" + val date = prefs.getString(dateKey, "未知") + appendLine(" $key: $value (日期: $date)") + } + } + } + } +} diff --git a/notification/src/main/java/com/remax/notification/utils/Topic.kt b/notification/src/main/java/com/remax/notification/utils/Topic.kt new file mode 100644 index 0000000..ae43b97 --- /dev/null +++ b/notification/src/main/java/com/remax/notification/utils/Topic.kt @@ -0,0 +1,8 @@ +package com.remax.notification.utils + +/** + * FCM 主题常量 + */ +object Topic { + const val ALL = "ALL" +} diff --git a/notification/src/main/java/com/remax/notification/worker/NotificationKeepAliveWorker.kt b/notification/src/main/java/com/remax/notification/worker/NotificationKeepAliveWorker.kt new file mode 100644 index 0000000..c29d45d --- /dev/null +++ b/notification/src/main/java/com/remax/notification/worker/NotificationKeepAliveWorker.kt @@ -0,0 +1,61 @@ +package com.remax.notification.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.remax.base.report.DataReportManager +import com.remax.notification.check.NotificationCheckController +import com.remax.notification.controller.NotificationTriggerController +import com.remax.notification.service.NotificationKeepAliveServiceManager +import com.remax.notification.timing.NotificationTimingController +import com.remax.notification.utils.NotiLogger +import com.remax.notification.utils.Topic +import kotlinx.coroutines.delay + +/** + * 通知保活 Worker + * 用于定期检查并确保常驻通知存在 + */ +class NotificationKeepAliveWorker( + val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "NotificationKeepAliveWorker" + private const val WORK_NAME = "notification_keep_alive" + + /** + * 创建保活工作请求 + * @param context 上下文 + * @return 工作请求 + */ + fun createWorkRequest(context: Context) { + // 这里可以添加具体的 WorkManager 配置 + // 例如:周期性工作、约束条件等 + NotiLogger.d("创建通知保活工作请求") + } + } + + override suspend fun doWork(): Result { + return try { + NotiLogger.d("开始执行通知保活任务") + + // 检查并确保常驻通知存在 + NotificationKeepAliveServiceManager.startKeepAliveService(context = context,from = "workManager") + + // 尝试触发通知 + NotificationTimingController.getInstance().triggerNotificationIfAllowed(NotificationCheckController.NotificationType.KEEPALIVE) + + // 等待一段时间确保通知发送完成 + delay(1000) + + NotiLogger.d("通知保活任务执行完成") + Result.success() + + } catch (e: Exception) { + NotiLogger.e("通知保活任务执行失败", e) + Result.failure() + } + } +} diff --git a/notification/src/main/res/drawable/ic_noti_audio.xml b/notification/src/main/res/drawable/ic_noti_audio.xml new file mode 100644 index 0000000..208e118 --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_audio.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_noti_document.xml b/notification/src/main/res/drawable/ic_noti_document.xml new file mode 100644 index 0000000..45f121a --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_document.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_noti_icon.xml b/notification/src/main/res/drawable/ic_noti_icon.xml new file mode 100644 index 0000000..652f3c4 --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/notification/src/main/res/drawable/ic_noti_photo.xml b/notification/src/main/res/drawable/ic_noti_photo.xml new file mode 100644 index 0000000..a96b0ae --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_photo.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_noti_recover.xml b/notification/src/main/res/drawable/ic_noti_recover.xml new file mode 100644 index 0000000..99cf0c1 --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_recover.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_noti_shot.xml b/notification/src/main/res/drawable/ic_noti_shot.xml new file mode 100644 index 0000000..53f20f3 --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_shot.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_noti_video.xml b/notification/src/main/res/drawable/ic_noti_video.xml new file mode 100644 index 0000000..1414353 --- /dev/null +++ b/notification/src/main/res/drawable/ic_noti_video.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/drawable/ic_resident_restore.xml b/notification/src/main/res/drawable/ic_resident_restore.xml new file mode 100644 index 0000000..74dad9d --- /dev/null +++ b/notification/src/main/res/drawable/ic_resident_restore.xml @@ -0,0 +1,12 @@ + + + + diff --git a/notification/src/main/res/drawable/ic_resident_restored.xml b/notification/src/main/res/drawable/ic_resident_restored.xml new file mode 100644 index 0000000..7b9c050 --- /dev/null +++ b/notification/src/main/res/drawable/ic_resident_restored.xml @@ -0,0 +1,12 @@ + + + + diff --git a/notification/src/main/res/drawable/ic_resident_tip.xml b/notification/src/main/res/drawable/ic_resident_tip.xml new file mode 100644 index 0000000..b0beb0f --- /dev/null +++ b/notification/src/main/res/drawable/ic_resident_tip.xml @@ -0,0 +1,11 @@ + + + diff --git a/notification/src/main/res/drawable/noti_bg_badge.xml b/notification/src/main/res/drawable/noti_bg_badge.xml new file mode 100644 index 0000000..8098e04 --- /dev/null +++ b/notification/src/main/res/drawable/noti_bg_badge.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/notification/src/main/res/drawable/noti_bg_button_primary.xml b/notification/src/main/res/drawable/noti_bg_button_primary.xml new file mode 100644 index 0000000..a057171 --- /dev/null +++ b/notification/src/main/res/drawable/noti_bg_button_primary.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/notification/src/main/res/drawable/noti_bg_r16_white.xml b/notification/src/main/res/drawable/noti_bg_r16_white.xml new file mode 100644 index 0000000..362fa56 --- /dev/null +++ b/notification/src/main/res/drawable/noti_bg_r16_white.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/notification/src/main/res/drawable/noti_bg_r8_blue.xml b/notification/src/main/res/drawable/noti_bg_r8_blue.xml new file mode 100644 index 0000000..252b890 --- /dev/null +++ b/notification/src/main/res/drawable/noti_bg_r8_blue.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/notification/src/main/res/drawable/noti_bg_r8_green.xml b/notification/src/main/res/drawable/noti_bg_r8_green.xml new file mode 100644 index 0000000..6933c36 --- /dev/null +++ b/notification/src/main/res/drawable/noti_bg_r8_green.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/notification/src/main/res/layout/layout_notification_general.xml b/notification/src/main/res/layout/layout_notification_general.xml new file mode 100644 index 0000000..dc908cd --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_general.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/layout/layout_notification_general_12.xml b/notification/src/main/res/layout/layout_notification_general_12.xml new file mode 100644 index 0000000..3527ba2 --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_general_12.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/layout/layout_notification_general_big.xml b/notification/src/main/res/layout/layout_notification_general_big.xml new file mode 100644 index 0000000..674f0bd --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_general_big.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/layout/layout_notification_general_big_12.xml b/notification/src/main/res/layout/layout_notification_general_big_12.xml new file mode 100644 index 0000000..f55e4bd --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_general_big_12.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/layout/layout_notification_resident.xml b/notification/src/main/res/layout/layout_notification_resident.xml new file mode 100644 index 0000000..6e66f50 --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_resident.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/layout/layout_notification_resident_12.xml b/notification/src/main/res/layout/layout_notification_resident_12.xml new file mode 100644 index 0000000..82a58a1 --- /dev/null +++ b/notification/src/main/res/layout/layout_notification_resident_12.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notification/src/main/res/values-ja/strings.xml b/notification/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..bc8c6d0 --- /dev/null +++ b/notification/src/main/res/values-ja/strings.xml @@ -0,0 +1,8 @@ + + + 復元済み + ファイルを復元 + %s ファイル + クリーン + 復元 + \ No newline at end of file diff --git a/notification/src/main/res/values-ko/strings.xml b/notification/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000..1bf3b69 --- /dev/null +++ b/notification/src/main/res/values-ko/strings.xml @@ -0,0 +1,8 @@ + + + 복원됨 + 파일 복원 + %s 파일 + 정리 + 복원 + \ No newline at end of file diff --git a/notification/src/main/res/values-night/themes.xml b/notification/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..2a710f3 --- /dev/null +++ b/notification/src/main/res/values-night/themes.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/notification/src/main/res/values-zh-rTW/strings.xml b/notification/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..dccf736 --- /dev/null +++ b/notification/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,8 @@ + + + 已恢復 + 恢復檔案 + %s 個檔案 + 清理 + 恢復 + \ No newline at end of file diff --git a/notification/src/main/res/values-zh/strings.xml b/notification/src/main/res/values-zh/strings.xml new file mode 100644 index 0000000..731e827 --- /dev/null +++ b/notification/src/main/res/values-zh/strings.xml @@ -0,0 +1,8 @@ + + + 已恢复 + 恢复文件 + %s 个文件 + 清理 + 恢复 + \ No newline at end of file diff --git a/notification/src/main/res/values/colors.xml b/notification/src/main/res/values/colors.xml new file mode 100644 index 0000000..7b71bd1 --- /dev/null +++ b/notification/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #4A85F3 + #3165FF + \ No newline at end of file diff --git a/notification/src/main/res/values/strings.xml b/notification/src/main/res/values/strings.xml new file mode 100644 index 0000000..af65ad7 --- /dev/null +++ b/notification/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + Restored + Restore Files + %s Files + Clean + Recovery + Photo Recovery + Service is running + \ No newline at end of file diff --git a/notification/src/main/res/values/ta_public_config.xml b/notification/src/main/res/values/ta_public_config.xml new file mode 100644 index 0000000..b5e8152 --- /dev/null +++ b/notification/src/main/res/values/ta_public_config.xml @@ -0,0 +1,4 @@ + + + true + \ No newline at end of file diff --git a/notification/src/main/res/values/themes.xml b/notification/src/main/res/values/themes.xml new file mode 100644 index 0000000..1832009 --- /dev/null +++ b/notification/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/notification/src/test/java/com/remax/notification/ExampleUnitTest.kt b/notification/src/test/java/com/remax/notification/ExampleUnitTest.kt new file mode 100644 index 0000000..1492a37 --- /dev/null +++ b/notification/src/test/java/com/remax/notification/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.remax.notification + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index d21c55b..2496eac 100644 --- a/settings.gradle +++ b/settings.gradle @@ -38,3 +38,4 @@ include ':app' include ':bill' include ':base' +include ':notification'