notification 初步

This commit is contained in:
renhaoting 2025-12-19 11:08:08 +08:00
parent 50341765a2
commit 8d3b2a6d03
66 changed files with 4826 additions and 0 deletions

View File

@ -62,6 +62,7 @@ protobuf {
dependencies { dependencies {
implementation project(':base') implementation project(':base')
implementation project(':notification')
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)

View File

@ -11,6 +11,7 @@ import androidx.viewpager2.widget.ViewPager2
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.activity.addCallback import androidx.activity.addCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import com.ama.core.architecture.appBase.AppViewsActivity import com.ama.core.architecture.appBase.AppViewsActivity
import com.ama.core.architecture.appBase.OnFragmentBackgroundListener import com.ama.core.architecture.appBase.OnFragmentBackgroundListener
import com.ama.core.architecture.ext.toast 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.fragments.task.DailySignSuccessDialog
import com.gamedog.vididin.main.interfaces.OnTabStyleListener import com.gamedog.vididin.main.interfaces.OnTabStyleListener
import com.gamedog.vididin.manager.DateChangeReceiver 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 dagger.hilt.android.AndroidEntryPoint
import kotlin.getValue import kotlin.getValue
import com.vididin.real.money.game.databinding.ActivityMainBinding as ViewBinding import com.vididin.real.money.game.databinding.ActivityMainBinding as ViewBinding
@ -38,6 +42,7 @@ import com.gamedog.vididin.main.MainViewModel as ViewModel
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>(), OnTabStyleListener { class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>(), OnTabStyleListener {
private lateinit var activityLauncher: ActivityLauncher
override val mViewModel: ViewModel by viewModels() override val mViewModel: ViewModel by viewModels()
private lateinit var navigatorAdapter: MainTabsAdapter private lateinit var navigatorAdapter: MainTabsAdapter
private val fragmentStateAdapter by lazy { MainViewPagerAdapter(this) } private val fragmentStateAdapter by lazy { MainViewPagerAdapter(this) }
@ -116,7 +121,18 @@ class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>(), OnTabS
} }
override fun ViewBinding.initObservers() { 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)
}
})
} }

View File

@ -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<Boolean> { 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)
}
}
}
}

View File

@ -62,6 +62,7 @@ databindingRuntime = "8.12.1"
fragmentKtx = "1.8.9" fragmentKtx = "1.8.9"
workRuntime = "2.9.0" workRuntime = "2.9.0"
firebaseBom = "34.1.0" firebaseBom = "34.1.0"
lifecycleProcess = "2.8.3"
[libraries] [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-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } 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-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-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-config = { group = "com.google.firebase", name = "firebase-config" } firebase-config = { group = "com.google.firebase", name = "firebase-config" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
@ -147,6 +150,7 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly
[plugins] [plugins]
# android # android
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }

1
notification/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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)
}

View File

21
notification/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -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)
}
}

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 通知权限 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 前台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- 网络权限(如果需要从网络获取通知内容) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 锁屏监听权限 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<!-- WorkManager 初始化 -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>
<!-- 通知删除监听器 -->
<receiver
android:name=".receiver.NotificationDeleteReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.remax.notification.ACTION_NOTIFICATION_DELETE" />
</intent-filter>
</receiver>
<provider
android:name=".provider.NotificationProvider"
android:authorities="${applicationId}.notification.provider"
android:exported="false" />
<!-- FCM 消息处理服务 -->
<service
android:name=".service.FCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- 前台保活服务 -->
<service
android:name=".service.NotificationKeepAliveService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse" />
</application>
</manifest>

View File

@ -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
}
}

View File

@ -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
}
]

View File

@ -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
)
}
}

View File

@ -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
}
}
}

View File

@ -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<Boolean, NotificationBlockReason?> {
// 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<Boolean, NotificationBlockReason?> {
// 检查通知次数限制
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<Boolean, NotificationBlockReason?> {
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"
}
}

View File

@ -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
}
}

View File

@ -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
)

View File

@ -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
}
}
}

View File

@ -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<PushContent>? = 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<PushContent> {
return try {
val type = object : TypeToken<List<PushContent>>() {}.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()
}
}

View File

@ -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
}
}

View File

@ -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,
)
)
}
}

View File

@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<out String>?): Int {
return 0
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
return 0
}
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
context?.let {
NotificationKeepAliveService.startService(it)
}
return super.call(method, arg, extras)
}
}

View File

@ -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()
}
}
}
}

View File

@ -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")
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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<NotificationKeepAliveWorker>(
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)
}
}
}

View File

@ -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}"
}
}

View File

@ -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)
}
}
}
}

View File

@ -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))
}
}
}

View File

@ -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
}
}

View File

@ -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)")
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package com.remax.notification.utils
/**
* FCM 主题常量
*/
object Topic {
const val ALL = "ALL"
}

View File

@ -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()
}
}
}

View File

@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<path
android:pathData="M34.211,22.032m-22.032,0a22.032,22.032 0,1 1,44.065 0a22.032,22.032 0,1 1,-44.065 0">
<aapt:attr name="android:fillColor">
<gradient
android:startX="14.664"
android:startY="3.338"
android:endX="55.621"
android:endY="34.844"
android:type="linear">
<item android:offset="0" android:color="#FFFD8C75"/>
<item android:offset="1" android:color="#FFFD535D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M27.97,34.97m-24.97,0a24.97,24.97 0,1 1,49.94 0a24.97,24.97 0,1 1,-49.94 0"
android:fillColor="#FD8070"
android:fillAlpha="0.4"/>
<path
android:pathData="M39.451,33.618C39.295,33.596 39.155,33.484 39.032,33.282C38.909,33.081 38.813,32.712 38.746,32.174C38.634,31.302 38.349,30.658 37.89,30.244C37.431,29.83 36.721,29.533 35.758,29.354C34.751,29.153 33.861,28.744 33.089,28.129C32.317,27.513 31.674,26.937 31.159,26.4C30.666,25.93 30.286,25.762 30.017,25.896C29.749,26.031 29.614,26.277 29.614,26.635V28.079V31.234C29.614,32.488 29.609,33.842 29.598,35.297C29.586,36.752 29.581,38.128 29.581,39.426V42.851V44.764C29.603,45.302 29.519,45.9 29.329,46.561C29.139,47.221 28.781,47.842 28.255,48.424C27.729,49.006 27.029,49.504 26.156,49.918C25.283,50.332 24.198,50.573 22.9,50.64C21.579,50.707 20.387,50.472 19.324,49.935C18.261,49.398 17.438,48.721 16.857,47.903C16.275,47.087 15.989,46.191 16,45.218C16.012,44.244 16.431,43.332 17.259,42.481C18.087,41.631 18.983,41.038 19.945,40.702C20.908,40.366 21.837,40.182 22.732,40.148C23.627,40.114 24.427,40.176 25.132,40.333C25.181,40.343 25.228,40.354 25.274,40.365C25.964,40.522 26.694,40 26.694,39.292V31.268C26.694,28.627 26.705,25.65 26.727,22.337C26.727,21.688 26.895,21.157 27.231,20.743C27.566,20.329 28.014,20.088 28.574,20.021C29.044,19.954 29.43,20.049 29.732,20.306C30.034,20.564 30.342,20.922 30.655,21.381C30.969,21.839 31.355,22.36 31.813,22.942C32.272,23.524 32.893,24.094 33.677,24.654C34.348,25.169 34.936,25.538 35.439,25.762C35.943,25.986 36.413,26.193 36.849,26.383C37.286,26.573 37.717,26.803 38.142,27.071C38.567,27.34 39.037,27.776 39.552,28.381C40.067,28.963 40.38,29.567 40.492,30.194C40.604,30.82 40.61,31.391 40.509,31.906C40.408,32.421 40.246,32.84 40.022,33.165C39.798,33.489 39.608,33.64 39.451,33.618Z"
android:fillColor="#ffffff"
android:fillAlpha="0.9"/>
</vector>

View File

@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<path
android:pathData="M55.521,18.426L35.715,14.012C35.649,13.997 35.587,13.97 35.533,13.932C35.478,13.893 35.431,13.844 35.395,13.787L31.43,7.547C31.251,7.264 31.017,7.019 30.743,6.826C30.468,6.634 30.158,6.497 29.831,6.425L16.003,3.344C15.342,3.197 14.65,3.319 14.079,3.682C13.508,4.045 13.105,4.619 12.957,5.279L5.629,38.159C5.483,38.82 5.604,39.512 5.967,40.083C6.33,40.654 6.905,41.057 7.565,41.206L48.416,50.309C49.076,50.456 49.768,50.334 50.339,49.971C50.91,49.608 51.314,49.034 51.462,48.374L57.457,21.472C57.604,20.811 57.482,20.12 57.119,19.549C56.756,18.978 56.181,18.574 55.521,18.426Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="15.436"
android:startY="6.33"
android:endX="44.121"
android:endY="48.227"
android:type="linear">
<item android:offset="0" android:color="#FFFDCA65"/>
<item android:offset="1" android:color="#FFFAB143"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M26.035,18.333H47.5C48.163,18.333 48.799,18.597 49.268,19.066C49.737,19.535 50,20.17 50,20.833V55.833C50,56.496 49.737,57.132 49.268,57.601C48.799,58.07 48.163,58.333 47.5,58.333H2.5C1.837,58.333 1.201,58.07 0.732,57.601C0.263,57.132 0,56.496 0,55.833V15.833C0,15.17 0.263,14.535 0.732,14.066C1.201,13.597 1.837,13.333 2.5,13.333H21.035L26.035,18.333Z"
android:fillColor="#FBBA50"
android:fillAlpha="0.3"/>
<path
android:pathData="M25,28.333C23.022,28.333 21.089,28.92 19.444,30.019C17.8,31.118 16.518,32.679 15.761,34.507C15.004,36.334 14.806,38.345 15.192,40.284C15.578,42.224 16.53,44.006 17.929,45.405C19.327,46.803 21.109,47.756 23.049,48.141C24.989,48.527 27,48.329 28.827,47.572C30.654,46.815 32.216,45.534 33.315,43.889C34.414,42.245 35,40.311 35,38.333H25V28.333Z"
android:fillColor="#ffffff"
android:fillAlpha="0.9"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="438dp"
android:height="500dp"
android:viewportWidth="438"
android:viewportHeight="500">
<path
android:pathData="M420.27,173.31C422.72,173.31 425.15,173.81 427.4,174.78C429.66,175.76 431.68,177.18 433.35,178.97C435.01,180.74 436.27,182.85 437.06,185.14C437.85,187.43 438.15,189.86 437.93,192.27V192.58L410.51,442.6C406.98,474.94 379.4,499.6 346.23,500H345.23L173.33,499.56C140.35,499.47 112.58,475.31 108.53,443.18L108.42,442.2L80.86,192.59C80.59,190.19 80.84,187.75 81.58,185.44C82.33,183.14 83.55,181.01 85.18,179.21C86.82,177.39 88.82,175.93 91.05,174.91C93.28,173.9 95.7,173.35 98.16,173.31H420.27ZM175.36,281.92L140.23,303.12L172.48,308.1C170.16,316.17 169.04,335.59 169.46,344.36C172.51,407.9 243.78,440.82 295.21,429.15C257.06,414.8 218.85,377.46 219.65,334.39C219.76,329.09 220.31,323.9 221.22,318.86L262.42,324.2L236.45,292.5L210.48,260.75L175.36,281.92ZM362.56,318.52C357.88,255.1 285.81,223.97 234.69,236.95C273.18,250.31 312.33,286.69 312.62,329.78C312.65,335.08 312.23,340.27 311.45,345.36L270.12,341L296.91,372.07L323.68,403.15L358.25,381.07L392.82,359.02L360.46,354.87C362.56,346.72 363.21,327.3 362.56,318.52ZM394.25,8.5C398.59,6.79 403.44,6.84 407.74,8.64C412.05,10.44 415.47,13.86 417.27,18.14C418.14,20.26 418.57,22.53 418.55,24.82C418.53,27.11 418.05,29.37 417.15,31.48C416.25,33.58 414.93,35.49 413.28,37.08C411.64,38.68 409.69,39.94 407.55,40.78L407.16,40.93L24.4,187.77C20.05,189.52 15.19,189.49 10.86,187.69C6.54,185.89 3.1,182.48 1.29,178.18C0.42,176.05 -0.02,173.77 0,171.48C0.02,169.19 0.5,166.92 1.41,164.81C2.32,162.7 3.64,160.79 5.29,159.19C6.95,157.59 8.91,156.34 11.06,155.49L11.45,155.34L394.25,8.5ZM299.29,112.98C301.68,112.97 303.98,113.88 305.7,115.52L361.21,170.09H237.33L292.9,115.58C294.61,113.93 296.9,112.99 299.29,112.98ZM246.88,0.81C251.23,-0.55 255.93,-0.19 260.03,1.8C264.13,3.8 267.29,7.28 268.88,11.53C270.42,15.77 270.24,20.45 268.37,24.56C266.5,28.67 263.09,31.9 258.86,33.55L258.48,33.71L128.6,83.62C124.22,85.29 119.37,85.19 115.07,83.35C110.77,81.51 107.39,78.06 105.66,73.74C104.03,69.65 104.02,65.1 105.64,61.01C107.27,56.92 110.39,53.59 114.39,51.7V51.67L246.88,0.81Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<clip-path
android:pathData="M0,0h60v60h-60z"/>
<path
android:pathData="M44.592,5.967C45.342,6.176 46.007,6.618 46.487,7.229C46.968,7.84 47.24,8.587 47.264,9.362L47.29,10.249C47.343,12 48.529,13.513 50.217,13.983L50.521,14.067C53.569,14.914 55.357,18.059 54.514,21.091L48.966,41.055C48.123,44.087 44.968,45.858 41.92,45.011L12.815,36.922C9.766,36.074 7.978,32.929 8.821,29.897L14.37,9.933C15.212,6.901 18.367,5.13 21.415,5.977L21.719,6.062C23.406,6.531 25.203,5.847 26.152,4.374L26.633,3.628C27.053,2.977 27.672,2.477 28.399,2.201C29.126,1.926 29.923,1.89 30.674,2.099L44.592,5.967Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="19.047"
android:startY="1.881"
android:endX="45.879"
android:endY="40.824"
android:type="linear">
<item android:offset="0" android:color="#FF7EE0FE"/>
<item android:offset="1" android:color="#FF338CFB"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M33.667,11.25C34.602,11.25 35.512,11.549 36.264,12.104C37.017,12.659 37.571,13.44 37.847,14.333L38.341,15.931C38.859,17.607 40.409,18.75 42.163,18.75H43.125C46.922,18.75 50,21.828 50,25.625V50.625C50,54.422 46.922,57.5 43.125,57.5H6.875C3.078,57.5 0,54.422 0,50.625V25.625C0,21.828 3.078,18.75 6.875,18.75H7.837C9.591,18.75 11.141,17.607 11.659,15.931L12.153,14.333C12.429,13.44 12.983,12.659 13.736,12.104C14.488,11.549 15.398,11.25 16.333,11.25H33.667Z"
android:fillColor="#439EFC"
android:fillAlpha="0.4"/>
<path
android:pathData="M24.375,37.5m-6.5,0a6.5,6.5 0,1 1,13 0a6.5,6.5 0,1 1,-13 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
</group>
</vector>

View File

@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<clip-path
android:pathData="M-4.286,-1.857h65.714v65.714h-65.714z"/>
<path
android:pathData="M49.09,8.228C50.069,8.527 50.895,9.189 51.4,10.079L57.902,21.534C58.444,22.489 58.569,23.626 58.248,24.677L49.473,53.399C49.298,53.969 48.891,54.443 48.342,54.716C47.792,54.989 47.145,55.039 46.542,54.855L8.346,43.185C7.746,42.997 7.24,42.594 6.937,42.061C6.635,41.529 6.56,40.91 6.729,40.34L18.662,1.282C19.025,0.094 20.338,-0.557 21.593,-0.173L49.09,8.228Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="20.726"
android:startY="3.153"
android:endX="51.574"
android:endY="45.867"
android:type="linear">
<item android:offset="0" android:color="#FF00EECD"/>
<item android:offset="1" android:color="#FF03DCBE"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M33.942,12.225C34.978,12.225 35.973,12.626 36.719,13.345L46.906,23.171C47.688,23.925 48.129,24.964 48.129,26.05V58.322C48.129,58.96 47.865,59.572 47.397,60.023C46.929,60.475 46.294,60.728 45.632,60.728H3.687C3.027,60.724 2.395,60.469 1.928,60.019C1.461,59.569 1.196,58.959 1.19,58.322V14.63C1.19,13.301 2.309,12.225 3.687,12.225H33.942Z"
android:fillColor="#02E0C2"
android:fillAlpha="0.4"/>
<path
android:pathData="M20.025,42.574C21.424,43.611 23.16,44.09 24.89,43.914C24.97,43.905 25.049,43.892 25.132,43.881C25.286,43.859 25.443,43.837 25.594,43.806C25.688,43.787 25.778,43.765 25.872,43.74C26.02,43.707 26.166,43.669 26.312,43.622C26.381,43.6 26.449,43.575 26.518,43.551C26.92,43.413 27.307,43.24 27.676,43.031L27.701,43.017C28.515,42.547 29.235,41.928 29.818,41.191C29.846,41.155 29.876,41.114 29.904,41.075C30.943,39.711 31.507,38.042 31.502,36.328H29.81C29.763,36.328 29.722,36.304 29.7,36.262C29.678,36.221 29.681,36.172 29.706,36.133L32.547,31.677C32.569,31.642 32.607,31.62 32.648,31.62C32.69,31.62 32.728,31.642 32.75,31.677L35.591,36.133C35.616,36.172 35.619,36.221 35.6,36.262C35.578,36.306 35.536,36.328 35.49,36.328H33.801C33.801,38.441 33.171,40.396 32.101,42.011C32.088,42.035 32.079,42.057 32.065,42.079C31.953,42.244 31.832,42.398 31.711,42.555C31.667,42.613 31.625,42.673 31.579,42.734C31.403,42.957 31.218,43.163 31.026,43.369L30.974,43.424C30.143,44.299 29.153,45.003 28.056,45.506L27.879,45.589C27.679,45.677 27.467,45.754 27.261,45.825C27.162,45.861 27.065,45.897 26.964,45.927C26.779,45.985 26.592,46.029 26.403,46.078C26.276,46.108 26.152,46.141 26.023,46.166C25.971,46.18 25.924,46.194 25.869,46.205C25.69,46.238 25.514,46.257 25.333,46.279L25.14,46.306C22.849,46.543 20.55,45.916 18.699,44.541C18.176,44.145 18.05,43.41 18.416,42.866C18.586,42.607 18.856,42.429 19.161,42.371C19.463,42.321 19.777,42.393 20.025,42.574ZM14.609,36.334C14.606,34.321 15.192,32.349 16.298,30.665C16.314,30.638 16.325,30.61 16.342,30.586C16.477,30.393 16.614,30.203 16.763,30.019L16.812,29.953C17.761,28.74 18.99,27.775 20.393,27.139C20.432,27.123 20.47,27.104 20.509,27.084C20.731,26.991 20.954,26.906 21.182,26.826C21.268,26.799 21.35,26.765 21.435,26.741C21.633,26.68 21.834,26.625 22.038,26.578C22.151,26.554 22.263,26.523 22.376,26.499C22.431,26.488 22.483,26.471 22.538,26.457C22.69,26.43 22.844,26.419 22.995,26.4C23.097,26.386 23.204,26.369 23.308,26.356C23.562,26.331 23.815,26.32 24.067,26.314C24.114,26.314 24.161,26.306 24.208,26.306C26.188,26.306 28.116,26.941 29.708,28.119C30.234,28.512 30.358,29.249 29.994,29.794C29.824,30.052 29.554,30.231 29.249,30.289C28.947,30.344 28.633,30.272 28.388,30.088C26.986,29.051 25.247,28.572 23.512,28.748L23.319,28.776C23.143,28.795 22.973,28.823 22.805,28.858C22.731,28.872 22.657,28.892 22.582,28.911C22.417,28.949 22.25,28.993 22.087,29.043L21.936,29.098C21.749,29.161 21.57,29.227 21.389,29.307L21.334,29.334C20.258,29.821 19.315,30.561 18.586,31.488L18.572,31.507C17.494,32.885 16.911,34.585 16.917,36.334H18.605C18.652,36.334 18.693,36.359 18.715,36.4C18.737,36.441 18.735,36.491 18.707,36.529L15.863,40.985C15.841,41.02 15.803,41.042 15.762,41.042C15.72,41.042 15.682,41.02 15.66,40.985L12.819,36.532C12.816,36.529 12.813,36.524 12.811,36.521C12.75,36.444 12.808,36.331 12.907,36.334H14.609Z"
android:fillColor="#ffffff"
android:fillAlpha="0.9"/>
</group>
</vector>

View File

@ -0,0 +1,33 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<clip-path
android:pathData="M0,0h60v60h-60z"/>
<path
android:pathData="M22.917,1.79L53.043,11.208A6,6 51.675,0 1,56.98 18.725L51.703,35.604A6,6 0,0 1,44.186 39.54L14.06,30.122A6,6 51.675,0 1,10.123 22.605L15.4,5.727A6,6 0,0 1,22.917 1.79z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="18.864"
android:startY="2.879"
android:endX="36.365"
android:endY="41.868"
android:type="linear">
<item android:offset="0" android:color="#FFFD8C75"/>
<item android:offset="1" android:color="#FFFD535D"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M6.833,18.333L48.833,18.333A6,6 0,0 1,54.833 24.333L54.833,54.333A6,6 0,0 1,48.833 60.333L6.833,60.333A6,6 0,0 1,0.833 54.333L0.833,24.333A6,6 0,0 1,6.833 18.333z"
android:fillColor="#FD7C6E"
android:fillAlpha="0.4"/>
<path
android:pathData="M18.972,30C20.012,30 20.853,30.862 20.853,31.928C20.853,32.994 20.012,33.856 18.972,33.856C17.932,33.856 17.09,32.994 17.09,31.928C17.09,30.862 17.932,30 18.972,30ZM39.657,49.286H15.673C14.418,49.286 13.715,47.799 14.492,46.788L20.681,38.734C21.284,37.951 22.445,37.951 23.048,38.734L25.622,42.087L30.624,33.118C31.203,32.08 32.66,32.08 33.239,33.118L40.966,46.978C41.539,48.006 40.815,49.286 39.657,49.286Z"
android:fillColor="#ffffff"
android:fillAlpha="0.9"/>
</group>
</vector>

View File

@ -0,0 +1,47 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="60dp"
android:height="60dp"
android:viewportWidth="60"
android:viewportHeight="60">
<group>
<clip-path
android:pathData="M0,0h60v60h-60z"/>
<path
android:pathData="M3.382,48.858C2.754,48.411 2.338,47.686 2.322,46.856L2.326,45.396L2.174,37.51L1.773,16.743C1.752,15.656 2.424,14.719 3.382,14.348V48.858ZM57.071,19.95C58.462,19.923 59.612,21.029 59.639,22.42L59.999,41.08C60.026,42.471 58.92,43.619 57.529,43.646L49.037,42.002V21.708L57.071,19.95Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="5.001"
android:startY="16.826"
android:endX="38.696"
android:endY="57.776"
android:type="linear">
<item android:offset="0" android:color="#FFFDCA65"/>
<item android:offset="1" android:color="#FFFAB143"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M15.475,13.043L42.693,13.043A4,4 0,0 1,46.693 17.044L46.693,45.565A4,4 0,0 1,42.693 49.565L15.475,49.565A4,4 0,0 1,11.475 45.565L11.475,17.044A4,4 0,0 1,15.475 13.043z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="13.461"
android:startY="15.904"
android:endX="47.005"
android:endY="40.855"
android:type="linear">
<item android:offset="0" android:color="#FFFDCA65"/>
<item android:offset="1" android:color="#FFFAB143"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M4,7.826L45.565,7.826A4,4 0,0 1,49.565 11.826L49.565,50.783A4,4 0,0 1,45.565 54.783L4,54.783A4,4 0,0 1,0 50.783L0,11.826A4,4 0,0 1,4 7.826z"
android:fillColor="#FBBA50"
android:fillAlpha="0.3"/>
<path
android:pathData="M32.737,30.565C33.312,30.962 33.313,31.812 32.739,32.21L21.81,39.791C21.147,40.251 20.241,39.777 20.24,38.971L20.222,23.835C20.221,23.028 21.126,22.552 21.789,23.01L32.737,30.565Z"
android:fillColor="#ffffff"
android:fillAlpha="0.9"/>
</group>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="34dp"
android:height="34dp"
android:viewportWidth="34"
android:viewportHeight="34">
<path
android:pathData="M6,0L28,0A6,6 0,0 1,34 6L34,28A6,6 0,0 1,28 34L6,34A6,6 0,0 1,0 28L0,6A6,6 0,0 1,6 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M27.286,26.39C27.098,27.202 26.518,29 25.572,29H8.429C7.482,29 6.714,28.533 6.714,27.955L5,19.604C5,19.028 5.768,18.56 6.714,18.56H9.822L10.143,16.472C10.143,15.703 10.91,15.08 11.857,15.08H19.142C19.537,14.321 20.109,13.34 20.429,13.34H28.143C28.937,13.368 29,15.47 29,15.95L27.286,26.39ZM25.857,16.82H13C12.211,16.82 11.572,17.327 11.572,17.95L11.475,18.56H22.143C23.09,18.56 23.67,18.995 23.858,19.604L25.484,27.53C25.514,27.445 25.546,27.367 25.572,27.26L27.286,17.69C27.286,17.065 26.646,16.82 25.857,16.82ZM11.358,12.218L13.699,13.603L5.447,15.41L6.21,7.447L8.352,9.804C8.674,9.263 9.377,7.992 9.716,7.427C11.11,5.103 13.069,4.268 17.471,5.718C15.825,5.85 14.997,6.037 13.072,8.138C12.155,9.139 11.406,11.393 11.358,12.218Z"
android:fillColor="#FEC827"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="34dp"
android:height="34dp"
android:viewportWidth="34"
android:viewportHeight="34">
<path
android:pathData="M6,0L28,0A6,6 0,0 1,34 6L34,28A6,6 0,0 1,28 34L6,34A6,6 0,0 1,0 28L0,6A6,6 0,0 1,6 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M25.424,16.62H26.799C27.56,16.62 28.076,17.217 27.951,17.968L26.211,28.333C26.176,28.518 26.077,28.685 25.933,28.807C25.789,28.928 25.608,28.996 25.419,29H7.729C7.54,28.993 7.359,28.924 7.214,28.803C7.069,28.683 6.968,28.517 6.927,28.333L5.026,17.968C4.99,17.802 4.991,17.63 5.031,17.465C5.072,17.3 5.149,17.146 5.258,17.016C5.367,16.886 5.505,16.782 5.66,16.713C5.815,16.645 5.985,16.613 6.154,16.62H7.532V12.495C7.532,11.732 8.156,11.118 8.904,11.118H12.562C12.79,11.118 13.08,11.267 13.212,11.454L14.364,13.051C14.498,13.238 14.796,13.387 15.017,13.387H24.608C24.716,13.387 24.823,13.408 24.923,13.45C25.022,13.491 25.113,13.552 25.188,13.629C25.264,13.706 25.324,13.797 25.365,13.897C25.405,13.997 25.425,14.104 25.424,14.212V16.62ZM22.256,7.069C23.96,7.256 31.121,8.583 28.383,17.411C27.723,18.378 29.756,11.415 22.254,10.456V12.43C22.254,12.71 22.079,12.804 21.863,12.67L16.056,9.132C15.854,9.01 15.84,8.784 16.056,8.652L21.863,5.062C22.091,4.918 22.254,5.036 22.254,5.302V7.069H22.256ZM8.904,15.243V16.62H24.044V15.243H8.902H8.904Z"
android:fillColor="#FFC926"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="8dp"
android:height="8dp"
android:viewportWidth="8"
android:viewportHeight="8">
<path
android:strokeWidth="1"
android:pathData="M4,4m-3.5,0a3.5,3.5 0,1 1,7 0a3.5,3.5 0,1 1,-7 0"
android:fillColor="#FE6100"
android:strokeColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF3B3F" />
<corners android:radius="30dp"/>
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/noti_primary" />
<corners android:radius="40dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="16dp" />
<solid android:color="#00000000"/>
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/noti_primary"/>
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="#01D4B9"/>
</shape>

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp">
<FrameLayout
android:layout_width="45dp"
android:layout_height="45dp">
<ImageView
android:id="@+id/iv"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:src="@drawable/ic_noti_photo" />
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/noti_bg_badge"
android:gravity="center"
android:minWidth="18dp"
android:minHeight="18dp"
android:paddingHorizontal="2dp"
android:text="12"
android:textColor="#fff"
android:textSize="11sp" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:textColor="#333333"
android:textSize="14sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
<TextView
android:id="@+id/tvDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:lines="1"
android:textColor="#666666"
android:textSize="12sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
</LinearLayout>
<TextView
android:id="@+id/tvAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/noti_bg_button_primary"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:text="@string/noti_clean"
android:textColor="#fff"
android:textSize="14sp" />
</LinearLayout>

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="45dp"
android:layout_height="45dp">
<ImageView
android:id="@+id/iv"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
android:src="@drawable/ic_noti_photo" />
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/noti_bg_badge"
android:gravity="center"
android:minWidth="18dp"
android:minHeight="18dp"
android:paddingHorizontal="2dp"
android:text="12"
android:textColor="#fff"
android:textSize="11sp" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:textColor="#333333"
android:textSize="14sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
<TextView
android:id="@+id/tvDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:lines="1"
android:textColor="#666666"
android:textSize="12sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
</LinearLayout>
<TextView
android:id="@+id/tvAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/noti_bg_button_primary"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:text="@string/noti_clean"
android:textColor="#fff"
android:textSize="14sp" />
</LinearLayout>

View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingHorizontal="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<FrameLayout
android:layout_width="60dp"
android:layout_height="60dp">
<ImageView
android:id="@+id/iv"
android:layout_width="45dp"
android:layout_height="45dp"
android:scaleType="centerCrop"
android:layout_gravity="center"
android:src="@drawable/ic_noti_photo" />
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/noti_bg_badge"
android:gravity="center"
android:minWidth="18dp"
android:minHeight="18dp"
android:paddingHorizontal="2dp"
android:text="12"
android:textColor="#fff"
android:textSize="11sp" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:id="@+id/tvTitle"
android:lines="1"
android:textColor="#333333"
android:textSize="16sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="13dp"
android:id="@+id/tvDesc"
android:ellipsize="end"
android:lines="2"
android:textColor="#666666"
android:textSize="14sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tvAction"
android:layout_width="match_parent"
android:layout_height="39dp"
android:layout_marginTop="29dp"
android:background="@drawable/noti_bg_button_primary"
android:gravity="center"
android:text="@string/noti_clean"
android:textColor="#fff"
android:textSize="14sp" />
</LinearLayout>

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<FrameLayout
android:layout_width="60dp"
android:layout_height="60dp">
<ImageView
android:id="@+id/iv"
android:layout_width="45dp"
android:layout_height="45dp"
android:scaleType="centerCrop"
android:layout_gravity="center"
android:src="@drawable/ic_noti_photo" />
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:background="@drawable/noti_bg_badge"
android:gravity="center"
android:minWidth="18dp"
android:minHeight="18dp"
android:paddingHorizontal="2dp"
android:text="12"
android:textColor="#fff"
android:textSize="11sp" />
</FrameLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="10dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:id="@+id/tvTitle"
android:lines="1"
android:textColor="#333333"
android:textSize="16sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="13dp"
android:id="@+id/tvDesc"
android:ellipsize="end"
android:lines="2"
android:textColor="#666666"
android:textSize="14sp"
tools:text="Storage summary readyStorage summary readyStorage summary readyStorage summary ready" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tvAction"
android:layout_width="match_parent"
android:layout_height="39dp"
android:layout_marginTop="29dp"
android:background="@drawable/noti_bg_button_primary"
android:gravity="center"
android:text="@string/noti_clean"
android:textColor="#fff"
android:textSize="14sp" />
</LinearLayout>

View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="64dp"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp">
<FrameLayout
android:id="@+id/llRestored"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/noti_bg_r8_green"
android:paddingHorizontal="12dp"
android:paddingVertical="7dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:layout_width="34dp"
android:layout_height="34dp"
android:src="@drawable/ic_resident_restored" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical|end"
android:orientation="vertical">
<TextView
android:id="@+id/restored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/noti_restored"
android:textColor="#fff"
android:textSize="12sp" />
<TextView
android:id="@+id/files"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:text="@string/noti_restore_file_count"
android:textColor="#fff"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="13dp"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/llRestoreFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/noti_bg_r8_blue">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="7dp">
<ImageView
android:layout_width="34dp"
android:layout_height="34dp"
android:src="@drawable/ic_resident_restore" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical|end">
<TextView
android:id="@+id/restore_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:text="@string/noti_restore_file"
android:textColor="#fff"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/ivPoint"
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_gravity="end"
android:src="@drawable/ic_resident_tip" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/noti_bg_r16_white"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/llRestored"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/noti_bg_r8_green"
android:paddingHorizontal="12dp"
android:paddingVertical="7dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:layout_width="34dp"
android:layout_height="34dp"
android:src="@drawable/ic_resident_restored" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical|end"
android:orientation="vertical">
<TextView
android:id="@+id/restored"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/noti_restored"
android:textColor="#fff"
android:textSize="12sp" />
<TextView
android:id="@+id/files"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:text="@string/noti_restore_file_count"
android:textColor="#fff"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="13dp"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/llRestoreFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/noti_bg_r8_blue">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="7dp">
<ImageView
android:layout_width="34dp"
android:layout_height="34dp"
android:src="@drawable/ic_resident_restore" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical|end">
<TextView
android:id="@+id/restore_file"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:text="@string/noti_restore_file"
android:textColor="#fff"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<ImageView
android:id="@+id/ivPoint"
android:layout_width="8dp"
android:layout_height="8dp"
android:layout_gravity="end"
android:src="@drawable/ic_resident_tip" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="noti_restored">復元済み</string>
<string name="noti_restore_file">ファイルを復元</string>
<string name="noti_restore_file_count">%s ファイル</string>
<string name="noti_clean">クリーン</string>
<string name="noti_recovery">復元</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="noti_restored">복원됨</string>
<string name="noti_restore_file">파일 복원</string>
<string name="noti_restore_file_count">%s 파일</string>
<string name="noti_clean">정리</string>
<string name="noti_recovery">복원</string>
</resources>

View File

@ -0,0 +1,3 @@
<resources xmlns:tools="http://schemas.android.com/tools">
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="noti_restored">已恢復</string>
<string name="noti_restore_file">恢復檔案</string>
<string name="noti_restore_file_count">%s 個檔案</string>
<string name="noti_clean">清理</string>
<string name="noti_recovery">恢復</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="noti_restored">已恢复</string>
<string name="noti_restore_file">恢复文件</string>
<string name="noti_restore_file_count">%s 个文件</string>
<string name="noti_clean">清理</string>
<string name="noti_recovery">恢复</string>
</resources>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="noti_color">#4A85F3</color>
<color name="noti_primary">#3165FF</color>
</resources>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="noti_restored">Restored</string>
<string name="noti_restore_file">Restore Files</string>
<string name="noti_restore_file_count">%s Files</string>
<string name="noti_clean">Clean</string>
<string name="noti_recovery">Recovery</string>
<string name="noti_resident_title">Photo Recovery</string>
<string name="noti_resident_service_running">Service is running</string>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="TAEnableBackgroundStartEvent">true</bool>
</resources>

View File

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ReMax_PhotoRecovery" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -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)
}
}

View File

@ -38,3 +38,4 @@ include ':app'
include ':bill' include ':bill'
include ':base' include ':base'
include ':notification'