notification 初步
This commit is contained in:
parent
50341765a2
commit
8d3b2a6d03
|
|
@ -62,6 +62,7 @@ protobuf {
|
|||
|
||||
dependencies {
|
||||
implementation project(':base')
|
||||
implementation project(':notification')
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import android.view.LayoutInflater
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.ama.core.architecture.appBase.AppViewsActivity
|
||||
import com.ama.core.architecture.appBase.OnFragmentBackgroundListener
|
||||
import com.ama.core.architecture.ext.toast
|
||||
|
|
@ -26,6 +27,9 @@ import com.gamedog.vididin.adapter.MainViewPagerAdapter
|
|||
import com.gamedog.vididin.main.fragments.task.DailySignSuccessDialog
|
||||
import com.gamedog.vididin.main.interfaces.OnTabStyleListener
|
||||
import com.gamedog.vididin.manager.DateChangeReceiver
|
||||
import com.gamedog.vididin.manager.NotificationController
|
||||
import com.remax.base.utils.ActivityLauncher
|
||||
import com.remax.notification.service.NotificationKeepAliveServiceManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlin.getValue
|
||||
import com.vididin.real.money.game.databinding.ActivityMainBinding as ViewBinding
|
||||
|
|
@ -38,6 +42,7 @@ import com.gamedog.vididin.main.MainViewModel as ViewModel
|
|||
@AndroidEntryPoint
|
||||
class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>(), OnTabStyleListener {
|
||||
|
||||
private lateinit var activityLauncher: ActivityLauncher
|
||||
override val mViewModel: ViewModel by viewModels()
|
||||
private lateinit var navigatorAdapter: MainTabsAdapter
|
||||
private val fragmentStateAdapter by lazy { MainViewPagerAdapter(this) }
|
||||
|
|
@ -116,7 +121,18 @@ class MainActivity : AppViewsActivity<ViewBinding, UiState, ViewModel>(), OnTabS
|
|||
}
|
||||
|
||||
override fun ViewBinding.initObservers() {
|
||||
activityLauncher = ActivityLauncher(this@MainActivity)
|
||||
|
||||
NotificationController.requestNotificationPermissionAsAsync(
|
||||
context = this@MainActivity,
|
||||
lifecycleScope = lifecycleScope,
|
||||
activityLauncher = activityLauncher!!,
|
||||
position = "Appstart",
|
||||
onGrantedOnlyUnauthorized = { isGranted->
|
||||
if(isGranted){
|
||||
NotificationKeepAliveServiceManager.startKeepAliveService(this@MainActivity)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ databindingRuntime = "8.12.1"
|
|||
fragmentKtx = "1.8.9"
|
||||
workRuntime = "2.9.0"
|
||||
firebaseBom = "34.1.0"
|
||||
lifecycleProcess = "2.8.3"
|
||||
|
||||
|
||||
[libraries]
|
||||
|
|
@ -138,6 +139,8 @@ androidx-databinding-runtime = { group = "androidx.databinding", name = "databin
|
|||
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
|
||||
androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" }
|
||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" }
|
||||
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
|
||||
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-config = { group = "com.google.firebase", name = "firebase-config" }
|
||||
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
|
||||
|
|
@ -147,6 +150,7 @@ firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashly
|
|||
|
||||
|
||||
|
||||
|
||||
[plugins]
|
||||
# android
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.remax.notification.utils
|
||||
|
||||
/**
|
||||
* FCM 主题常量
|
||||
*/
|
||||
object Topic {
|
||||
const val ALL = "ALL"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="TAEnableBackgroundStartEvent">true</bool>
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,3 +38,4 @@ include ':app'
|
|||
|
||||
include ':bill'
|
||||
include ':base'
|
||||
include ':notification'
|
||||
|
|
|
|||
Loading…
Reference in New Issue