新增bill模块
This commit is contained in:
parent
ba4c5708db
commit
96a3e641ce
|
|
@ -70,7 +70,7 @@ dependencies {
|
|||
implementation(project(":core:architecture"))
|
||||
//api(project(":core:architecture-reflect"))
|
||||
implementation(project(":core:network"))
|
||||
|
||||
implementation(project(":bill"))
|
||||
implementation(project(":youtube:core"))
|
||||
implementation(project(":youtube:custom-ui"))
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
ext {
|
||||
// AdMob配置
|
||||
admob = [applicationId: "ca-app-pub-3940256099942544~3347511713", // 测试应用ID,请替换为实际的AdMob应用ID
|
||||
adUnitIds : [banner : "ca-app-pub-3940256099942544/9214589741", // 横幅广告测试ID
|
||||
interstitial: "ca-app-pub-3940256099942544/1033173712", // 插页广告测试ID
|
||||
splash : "ca-app-pub-3940256099942544/9257395921", // 开屏广告测试ID
|
||||
native : "ca-app-pub-3940256099942544/2247696110", // 原生广告测试ID
|
||||
full_native : "ca-app-pub-3940256099942544/2247696110", // 全屏原生广告测试ID
|
||||
rewarded : "ca-app-pub-3940256099942544/5224354917" // 激励广告测试ID
|
||||
]]
|
||||
|
||||
// Pangle配置
|
||||
pangle = [applicationId: "8025677", // Pangle测试应用ID
|
||||
adUnitIds : [splash : "890000078", // 开屏广告测试ID(竖屏)
|
||||
banner : "980099802", // 横幅广告测试ID(320x50)
|
||||
interstitial: "980088188", // 插页广告测试ID(竖屏)
|
||||
native : "980088216", // 原生广告测试ID
|
||||
full_native : "980088216", // 全屏原生广告测试ID
|
||||
rewarded : "980088192" // 激励视频测试ID(竖屏)
|
||||
]]
|
||||
|
||||
// TopOn配置
|
||||
topon = [applicationId: "a5aa1f9deda26d", // TopOn 应用 ID(需替换为实际值)
|
||||
appKey : "4f7b9ac17decb9babec83aac078742c7", // TopOn 应用密钥(需替换为实际值)
|
||||
adUnitIds : [interstitial: "b5baca53984692", // 插页广告位 ID(需替换为实际值)
|
||||
rewarded : "b5b449fb3d89d7", // 激励广告位 ID
|
||||
native : "b5aa1fa2cae775", // 原生广告位 ID(需替换为实际值)
|
||||
splash : "b5f73fe0c5db29", // 开屏广告位 ID(需替换为实际值)
|
||||
full_native : "b5aa1fa501d9f6", // 全屏原生广告位 ID(需替换为实际值)
|
||||
banner : "b5baca4f74c3d8"] // 横幅广告位 ID(需替换为实际值)
|
||||
]
|
||||
|
||||
// 应用版本配置
|
||||
app = [applicationId: "com.remax.video.recovery",
|
||||
compileSdk : 35,
|
||||
minSdk : libs.versions.minSdk.get().toInteger(),
|
||||
targetSdk : 35,
|
||||
versionCode : 1,
|
||||
versionName : "1.0.2"]
|
||||
|
||||
url = [privacyUrl: "https://www.google.com",
|
||||
teamUrl : "https://www.google.com",]
|
||||
|
||||
// 统计归因配置
|
||||
analytics = [adjustAppToken: "h6qax9dxv7cw", // Adjust App Token
|
||||
thinkingDataAppId: "61b7ef0186b74b76b301b67184b7b48b", // 数数 SDK APP ID
|
||||
thinkingDataServerUrl: "https://xray.alifmd.com", // 数数上报域名
|
||||
defaultUserChannel: "paid"] // 默认用户渠道,internal默认paid
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
ext {
|
||||
// AdMob配置 - Play 市场版本
|
||||
admob = [applicationId: "ca-app-pub-1350364678590045~1984631821", // Play市场AdMob应用ID
|
||||
adUnitIds : [banner : "ca-app-pub-1350364678590045/8582717815", // 横幅广告正式ID
|
||||
interstitial: "ca-app-pub-1350364678590045/5193588407", // 插页广告正式ID
|
||||
splash : "ca-app-pub-1350364678590045/5568653612", // 开屏广告正式ID
|
||||
native : "ca-app-pub-1350364678590045/8003245260", // 原生广告正式ID
|
||||
full_native : "ca-app-pub-1350364678590045/1219233112" // 原生广告正式ID
|
||||
]]
|
||||
|
||||
// Pangle配置
|
||||
pangle = [applicationId: "8750604", // Pangle测试应用ID
|
||||
adUnitIds : [splash : "", // 开屏广告测试ID(竖屏)
|
||||
banner : "", // 横幅广告测试ID(320x50)
|
||||
interstitial: "982604080", // 插页广告测试ID(竖屏)
|
||||
native : "", // 原生广告测试ID
|
||||
full_native : "", // 全屏原生广告测试ID
|
||||
rewarded : "" // 激励视频测试ID(竖屏)
|
||||
]]
|
||||
|
||||
// TopOn配置
|
||||
topon = [applicationId: "h1gq3c2vm973ma", // TopOn 应用 ID(需替换为实际值)
|
||||
appKey : "a96bffecc1c32132c6984a3e97512b5f9", // TopOn 应用密钥(需替换为实际值)
|
||||
adUnitIds : [interstitial: "n1gq3c2vobnfr5", // 插页广告位 ID(需替换为实际值)
|
||||
rewarded : "", // 激励广告位 ID
|
||||
native : "n1gq3c2vpbobmj", // 原生广告位 ID(需替换为实际值)
|
||||
splash : "n1gq3c2vppegpc", // 开屏广告位 ID(需替换为实际值)
|
||||
full_native : "n1gq3c2volk4ad", // 全屏原生广告位 ID(需替换为实际值)
|
||||
banner : "n1gq3c2vq30fsn"] // 横幅广告位 ID(需替换为实际值)
|
||||
]
|
||||
|
||||
// 应用版本配置 - Play 市场版本
|
||||
app = [applicationId: "com.files.restore.recovery.tool.deleted.document.photo.video.audio.app.mobile.scan.utility",
|
||||
compileSdk : 35,
|
||||
minSdk : libs.versions.minSdk.get().toInteger(),
|
||||
targetSdk : 35,
|
||||
versionCode : 3,
|
||||
versionName : "1.0.2"]
|
||||
|
||||
url = [privacyUrl: "https://alifmd.com/privacy.html",
|
||||
teamUrl : "https://alifmd.com/privacy.html",]
|
||||
|
||||
// 统计归因配置
|
||||
analytics = [adjustAppToken: "h6qax9dxv7cw", // Adjust App Token
|
||||
thinkingDataAppId: "61b7ef0186b74b76b301b67184b7b48b", // 数数 SDK APP ID
|
||||
thinkingDataServerUrl: "https://xray.alifmd.com",// 数数上报域名
|
||||
defaultUserChannel: "natural"] //默认渠道
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
plugins {
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.kotlinAndroid)
|
||||
//alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.room)
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
apply {
|
||||
from("../app/config_debug.gradle")
|
||||
}
|
||||
|
||||
val appConfig = findProperty("app") as Map<*, *>
|
||||
val analyticsConfig = findProperty("analytics") as Map<*, *>
|
||||
|
||||
|
||||
android {
|
||||
namespace = "com.remax.base"
|
||||
compileSdk = appConfig["compileSdk"] as Int
|
||||
|
||||
defaultConfig {
|
||||
minSdk = appConfig["minSdk"] as Int
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "DEFAULT_USER_CHANNEL", "\"${analyticsConfig["defaultUserChannel"]}\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
api(libs.utilcodex)
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.androidx.lifecycle.runtime.ktx)
|
||||
api(libs.androidx.room.runtime)
|
||||
api(libs.androidx.databinding.runtime)
|
||||
api(libs.constraintlayout)
|
||||
api(libs.material)
|
||||
kapt(libs.androidx.room.compiler)
|
||||
api(libs.androidx.room.ktx)
|
||||
api(libs.androidx.fragment.ktx)
|
||||
api(libs.androidx.work.runtime)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Glide图片加载库
|
||||
api(libs.glide)
|
||||
kapt(libs.glide.compiler)
|
||||
|
||||
// Lottie动画库
|
||||
api(libs.lottie)
|
||||
|
||||
api(platform(libs.firebase.bom))
|
||||
api(libs.firebase.config)
|
||||
api(libs.firebase.analytics)
|
||||
api(libs.firebase.crashlytics)
|
||||
|
||||
// Gson for JSON parsing
|
||||
api(libs.gson)
|
||||
}
|
||||
|
|
@ -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.base
|
||||
|
||||
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.base.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<!-- BaseModuleProvider 最高优先级,提供 Context -->
|
||||
<provider
|
||||
android:name="com.remax.base.provider.BaseModuleProvider"
|
||||
android:authorities="${applicationId}.base.provider"
|
||||
android:exported="false"
|
||||
android:initOrder="110" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.remax.base.ads
|
||||
|
||||
/**
|
||||
* 广告收益数据
|
||||
* @param revenue 单次展示收益(值和货币)
|
||||
* @param adRevenueNetwork 广告平台名称
|
||||
* @param adRevenueUnit 广告位ID
|
||||
* @param adRevenuePlacement 广告源ID
|
||||
* @param adFormat 广告格式
|
||||
*/
|
||||
data class AdRevenueData(
|
||||
val revenue: RevenueInfo,
|
||||
val adRevenueNetwork: String,
|
||||
val adRevenueUnit: String,
|
||||
val adRevenuePlacement: String,
|
||||
val adFormat: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益信息
|
||||
* @param value 收益值(USD)
|
||||
* @param currencyCode 货币代码
|
||||
*/
|
||||
data class RevenueInfo(
|
||||
val value: Double,
|
||||
val currencyCode: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 广告收益上报管理器接口
|
||||
* 用于上报广告收益数据到第三方分析平台
|
||||
*/
|
||||
interface AdRevenueReporter {
|
||||
|
||||
/**
|
||||
* 上报广告收益数据
|
||||
* @param adRevenueData 广告收益数据
|
||||
*/
|
||||
fun reportAdRevenue(adRevenueData: AdRevenueData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告收益上报管理器
|
||||
* 单例模式,支持外部注入具体实现
|
||||
*/
|
||||
object AdRevenueManager {
|
||||
|
||||
private val reporters = mutableListOf<AdRevenueReporter>()
|
||||
|
||||
/**
|
||||
* 设置广告收益上报器实现
|
||||
* @param reporter 具体的上报器实现
|
||||
*/
|
||||
fun setReporter(reporter: AdRevenueReporter) {
|
||||
reporters.clear()
|
||||
reporters.add(reporter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置多个广告收益上报器实现
|
||||
* @param reporters 上报器实现集合
|
||||
*/
|
||||
fun setReporters(reporters: Collection<AdRevenueReporter>) {
|
||||
this.reporters.clear()
|
||||
this.reporters.addAll(reporters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加广告收益上报器实现
|
||||
* @param reporter 具体的上报器实现
|
||||
*/
|
||||
fun addReporter(reporter: AdRevenueReporter) {
|
||||
if (!reporters.contains(reporter)) {
|
||||
reporters.add(reporter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除广告收益上报器实现
|
||||
* @param reporter 要移除的上报器实现
|
||||
*/
|
||||
fun removeReporter(reporter: AdRevenueReporter) {
|
||||
reporters.remove(reporter)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据
|
||||
* @param adRevenueData 广告收益数据
|
||||
*/
|
||||
fun reportAdRevenue(adRevenueData: AdRevenueData) {
|
||||
try {
|
||||
reporters.forEach { reporter ->
|
||||
try {
|
||||
reporter.reportAdRevenue(adRevenueData)
|
||||
} catch (e: Exception) {
|
||||
// 单个上报器异常不影响其他上报器
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 静默处理异常,避免影响广告正常展示
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有上报器
|
||||
*/
|
||||
fun clearReporters() {
|
||||
reporters.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前上报器数量
|
||||
* @return 上报器数量
|
||||
*/
|
||||
fun getReporterCount(): Int = reporters.size
|
||||
|
||||
/**
|
||||
* 检查是否有上报器
|
||||
* @return 是否有上报器
|
||||
*/
|
||||
fun hasReporters(): Boolean = reporters.isNotEmpty()
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.remax.base.controller
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* System Page Navigation State Controller
|
||||
* Used to manage state when returning from system pages, preventing splash screen from launching
|
||||
*/
|
||||
object SystemPageNavigationController {
|
||||
|
||||
private const val TAG = "SystemPageNavigation"
|
||||
|
||||
// Whether currently in system page
|
||||
private val isInSystemPage = AtomicBoolean(false)
|
||||
|
||||
// Timestamp when entering system page
|
||||
private val enterSystemPageTime = AtomicLong(0)
|
||||
|
||||
// System page timeout (milliseconds) - 5 minutes
|
||||
private const val SYSTEM_PAGE_TIMEOUT = 5 * 60 * 1000L
|
||||
|
||||
// System page types
|
||||
enum class SystemPageType {
|
||||
SETTINGS, // App settings page
|
||||
Notification_PERMISSION, // Permission settings page
|
||||
STORAGE_ACCESS, // Storage access permission page
|
||||
UNKNOWN // Unknown page
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entering system page
|
||||
*/
|
||||
fun markEnterSystemPage(type: SystemPageType = SystemPageType.UNKNOWN) {
|
||||
isInSystemPage.set(true)
|
||||
enterSystemPageTime.set(System.currentTimeMillis())
|
||||
Log.d(TAG, "Marked entering system page: $type")
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark leaving system page
|
||||
*/
|
||||
fun markLeaveSystemPage() {
|
||||
isInSystemPage.set(false)
|
||||
enterSystemPageTime.set(0)
|
||||
Log.d(TAG, "Marked leaving system page")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently in system page
|
||||
*/
|
||||
fun isInSystemPage(): Boolean {
|
||||
return isInSystemPage.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if splash screen should be started
|
||||
* If just returned from system page, don't start
|
||||
*/
|
||||
fun shouldStartSplashActivity(): Boolean {
|
||||
val inSystemPage = isInSystemPage.get()
|
||||
if (!inSystemPage) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if timeout
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val enterTime = enterSystemPageTime.get()
|
||||
if (enterTime > 0 && (currentTime - enterTime) > SYSTEM_PAGE_TIMEOUT) {
|
||||
Log.w(TAG, "System page timeout, resetting state")
|
||||
markLeaveSystemPage()
|
||||
return true
|
||||
}
|
||||
|
||||
Log.d(TAG, "Just returned from system page, not starting splash screen")
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entering storage access permission page (Android 11+)
|
||||
* Used for permission requests in StoragePermissionExt
|
||||
*/
|
||||
fun markEnterStorageAccessPage() {
|
||||
markEnterSystemPage(SystemPageType.STORAGE_ACCESS)
|
||||
}
|
||||
|
||||
fun markEnterNotificationAccessPage() {
|
||||
markEnterSystemPage(SystemPageType.Notification_PERMISSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if splash screen should be started (AppLifecycleObserver specific)
|
||||
* If just returned from system page, don't start
|
||||
*/
|
||||
fun shouldStartSplashForAppLifecycle(): Boolean {
|
||||
val inSystemPage = isInSystemPage.get()
|
||||
if (!inSystemPage) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if timeout
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val enterTime = enterSystemPageTime.get()
|
||||
if (enterTime > 0 && (currentTime - enterTime) > SYSTEM_PAGE_TIMEOUT) {
|
||||
Log.w(TAG, "System page timeout, resetting state")
|
||||
markLeaveSystemPage()
|
||||
return true
|
||||
}
|
||||
|
||||
Log.d(TAG, "Just returned from system page, not starting splash screen")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package com.remax.base.controller
|
||||
|
||||
import com.remax.base.BuildConfig
|
||||
import com.remax.base.ext.KvBoolDelegate
|
||||
import com.remax.base.ext.KvStringDelegate
|
||||
import com.remax.base.log.BaseLogger
|
||||
|
||||
/**
|
||||
* 用户渠道控制器
|
||||
* 统一管理用户渠道类型,提供渠道设置和监听功能
|
||||
*/
|
||||
object UserChannelController {
|
||||
|
||||
private const val TAG = "UserChannelController"
|
||||
private const val KEY_USER_CHANNEL = "user_channel_type"
|
||||
private const val KEY_CHANNEL_SET_ONCE = "user_channel_set_once"
|
||||
|
||||
/**
|
||||
* 用户渠道类型枚举
|
||||
*/
|
||||
enum class UserChannelType(val value: String) {
|
||||
NATURAL("natural"), // 自然渠道
|
||||
PAID("paid") // 买量渠道
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道变化监听接口
|
||||
*/
|
||||
interface ChannelChangeListener {
|
||||
/**
|
||||
* 渠道类型变化回调
|
||||
* @param oldChannel 旧渠道类型
|
||||
* @param newChannel 新渠道类型
|
||||
*/
|
||||
fun onChannelChanged(oldChannel: UserChannelType, newChannel: UserChannelType)
|
||||
}
|
||||
|
||||
// 使用KvStringDelegate进行持久化存储
|
||||
private var channelTypeString by KvStringDelegate(KEY_USER_CHANNEL, getDefaultChannelValue())
|
||||
private var channelSetOnce by KvBoolDelegate(KEY_CHANNEL_SET_ONCE, false)
|
||||
|
||||
// 监听器列表
|
||||
private val listeners = mutableListOf<ChannelChangeListener>()
|
||||
|
||||
/**
|
||||
* 获取默认渠道值
|
||||
* 优先使用BuildConfig中的配置,如果没有则使用NATURAL作为默认值
|
||||
* @return 默认渠道值
|
||||
*/
|
||||
private fun getDefaultChannelValue(): String {
|
||||
return try {
|
||||
// 尝试从BuildConfig获取默认渠道配置
|
||||
val defaultChannel = BuildConfig.DEFAULT_USER_CHANNEL
|
||||
|
||||
// 验证配置值是否有效
|
||||
if (UserChannelType.values().any { it.value == defaultChannel }) {
|
||||
BaseLogger.d("使用BuildConfig默认渠道: %s", defaultChannel)
|
||||
defaultChannel
|
||||
} else {
|
||||
BaseLogger.w("BuildConfig默认渠道无效: %s,使用NATURAL", defaultChannel)
|
||||
UserChannelType.NATURAL.value
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 如果无法获取BuildConfig或字段不存在,使用默认值
|
||||
BaseLogger.e("获取BuildConfig默认渠道失败,使用NATURAL", e)
|
||||
UserChannelType.NATURAL.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户渠道类型
|
||||
* @return 当前渠道类型,默认为自然渠道
|
||||
*/
|
||||
fun getCurrentChannel(): UserChannelType {
|
||||
return try {
|
||||
val currentChannelString = channelTypeString
|
||||
if (currentChannelString.isNullOrEmpty()) {
|
||||
BaseLogger.w("渠道字符串为空,使用默认NATURAL")
|
||||
return UserChannelType.NATURAL
|
||||
}
|
||||
|
||||
UserChannelType.values().find { it.value == currentChannelString }
|
||||
?: run {
|
||||
BaseLogger.w("无效的渠道字符串: %s,使用默认NATURAL", currentChannelString)
|
||||
UserChannelType.NATURAL
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
BaseLogger.e("获取当前渠道失败,使用默认NATURAL", e)
|
||||
UserChannelType.NATURAL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户渠道类型
|
||||
* @param channelType 新的渠道类型
|
||||
* @return 是否成功设置(如果已经设置过则返回false)
|
||||
*/
|
||||
fun setChannel(channelType: UserChannelType): Boolean {
|
||||
// 如果已经设置过,则不再允许修改
|
||||
if (channelSetOnce) {
|
||||
BaseLogger.w("用户渠道已设置过,无法修改: %s", getCurrentChannel().value)
|
||||
return false
|
||||
}
|
||||
|
||||
val oldChannel = getCurrentChannel()
|
||||
if (oldChannel != channelType) {
|
||||
channelTypeString = channelType.value
|
||||
channelSetOnce = true // 标记为已设置
|
||||
|
||||
BaseLogger.d("用户渠道设置成功: %s -> %s", oldChannel.value, channelType.value)
|
||||
|
||||
// 通知所有监听器
|
||||
notifyChannelChanged(oldChannel, channelType)
|
||||
} else {
|
||||
BaseLogger.d("用户渠道未变化,保持: %s", channelType.value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加渠道变化监听器
|
||||
* @param listener 监听器
|
||||
*/
|
||||
fun addChannelChangeListener(listener: ChannelChangeListener) {
|
||||
if (!listeners.contains(listener)) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除渠道变化监听器
|
||||
* @param listener 监听器
|
||||
*/
|
||||
fun removeChannelChangeListener(listener: ChannelChangeListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有监听器
|
||||
*/
|
||||
fun clearListeners() {
|
||||
listeners.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器渠道变化
|
||||
* @param oldChannel 旧渠道类型
|
||||
* @param newChannel 新渠道类型
|
||||
*/
|
||||
private fun notifyChannelChanged(oldChannel: UserChannelType, newChannel: UserChannelType) {
|
||||
BaseLogger.d("通知渠道变化监听器,监听器数量: %d", listeners.size)
|
||||
listeners.forEach { listener ->
|
||||
try {
|
||||
listener.onChannelChanged(oldChannel, newChannel)
|
||||
} catch (e: Exception) {
|
||||
// 忽略监听器异常,避免影响其他监听器
|
||||
BaseLogger.e("渠道变化监听器异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为自然渠道
|
||||
* @return 是否为自然渠道
|
||||
*/
|
||||
fun isNaturalChannel(): Boolean {
|
||||
return getCurrentChannel() == UserChannelType.NATURAL
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为买量渠道
|
||||
* @return 是否为买量渠道
|
||||
*/
|
||||
fun isPaidChannel(): Boolean {
|
||||
return getCurrentChannel() == UserChannelType.PAID
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已经设置过渠道
|
||||
* @return 是否已经设置过
|
||||
*/
|
||||
fun isChannelSetOnce(): Boolean {
|
||||
return channelSetOnce
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置渠道设置状态(仅用于测试或特殊情况)
|
||||
* 注意:此方法会清除已设置标记,允许重新设置渠道
|
||||
*/
|
||||
fun resetChannelSetting() {
|
||||
channelSetOnce = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制设置渠道类型(忽略已设置标记)
|
||||
* 注意:此方法仅用于特殊情况,如测试或数据迁移
|
||||
* @param channelType 新的渠道类型
|
||||
*/
|
||||
fun forceSetChannel(channelType: UserChannelType) {
|
||||
val oldChannel = getCurrentChannel()
|
||||
channelTypeString = channelType.value
|
||||
channelSetOnce = true
|
||||
|
||||
BaseLogger.d("强制设置用户渠道: %s -> %s", oldChannel.value, channelType.value)
|
||||
|
||||
// 通知所有监听器
|
||||
notifyChannelChanged(oldChannel, channelType)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取渠道类型字符串(用于日志等)
|
||||
* @return 渠道类型字符串
|
||||
*/
|
||||
fun getChannelString(): String {
|
||||
return getCurrentChannel().value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.remax.base.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.remax.base.utils.ContextProvider
|
||||
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
val kv: SharedPreferences by lazy {
|
||||
ContextProvider.getAppContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
val kvEditor: SharedPreferences.Editor by lazy {
|
||||
kv.edit()
|
||||
}
|
||||
|
||||
class KvStringDelegate(private val key: String, private val def: String? = null) :
|
||||
ReadWriteProperty<Any?, String?> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): String? {
|
||||
return kv.getString(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) {
|
||||
kvEditor.putString(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
class KvLongDelegate(private val key: String, private val def: Long = 0L) :
|
||||
ReadWriteProperty<Any?, Long> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Long {
|
||||
return kv.getLong(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Long) {
|
||||
kvEditor.putLong(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
class KvBoolDelegate(private val key: String, private val def: Boolean = false) :
|
||||
ReadWriteProperty<Any?, Boolean> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Boolean {
|
||||
return kv.getBoolean(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Boolean) {
|
||||
kvEditor.putBoolean(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
class KvFloatDelegate(private val key: String, private val def: Float = 0f) :
|
||||
ReadWriteProperty<Any?, Float> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Float {
|
||||
return kv.getFloat(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Float) {
|
||||
kvEditor.putFloat(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class KvIntDelegate(private val key: String, private val def: Int = 0) :
|
||||
ReadWriteProperty<Any?, Int> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Int {
|
||||
return kv.getInt(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
|
||||
kvEditor.putInt(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
class KvStringSetDelegate(private val key: String, private val def: Set<String> = setOf()) :
|
||||
ReadWriteProperty<Any?, Set<String>?> {
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): Set<String>? {
|
||||
return kv.getStringSet(key, def)
|
||||
}
|
||||
|
||||
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Set<String>?) {
|
||||
kvEditor.putStringSet(key, value).apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.remax.base.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.remax.base.utils.ActivityLauncher
|
||||
|
||||
/**
|
||||
* 检查是否可以发送通知(权限 + 系统设置)
|
||||
*/
|
||||
fun Context.canSendNotification(): Boolean {
|
||||
// 检查权限
|
||||
val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true // Android 13 以下默认有权限
|
||||
}
|
||||
|
||||
// 检查系统设置
|
||||
val isEnabled = try {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
} catch (e: Exception) {
|
||||
true // 异常时默认返回 true
|
||||
}
|
||||
|
||||
return hasPermission && isEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
* @param launcher ActivityLauncher 实例
|
||||
* @param result 权限结果回调
|
||||
*/
|
||||
fun Context. requestNotificationPermission(
|
||||
launcher: ActivityLauncher,
|
||||
result: (flag: Boolean) -> Unit
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Android 13+ 需要请求通知权限
|
||||
launcher.launch(arrayOf(Manifest.permission.POST_NOTIFICATIONS)) { permissions ->
|
||||
val isGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: false
|
||||
result(isGranted)
|
||||
}
|
||||
} else {
|
||||
// Android 13 以下版本默认有权限,但需要检查系统设置
|
||||
result(canSendNotification())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到系统通知设置页面
|
||||
* @param launcher ActivityLauncher 实例
|
||||
* @param result 设置结果回调
|
||||
*/
|
||||
fun Context.openNotificationSettings(
|
||||
launcher: ActivityLauncher,
|
||||
result: (flag: Boolean) -> Unit
|
||||
) {
|
||||
val intent = Intent().apply {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
|
||||
// Android 8.0+ 使用应用通知设置
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
else -> {
|
||||
// Android 8.0 以下使用通用通知设置
|
||||
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||
data = Uri.fromParts("package", packageName, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launcher.launch(intent) { activityResult ->
|
||||
// 检查设置后的状态
|
||||
result(canSendNotification())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.remax.base.ext
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.remax.base.utils.ActivityLauncher
|
||||
import com.remax.base.controller.SystemPageNavigationController
|
||||
|
||||
fun Context.checkStorePermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
val readPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
val writePermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
return readPermission == PackageManager.PERMISSION_GRANTED && writePermission == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.requestStorePermission(
|
||||
launcher: ActivityLauncher,
|
||||
jumpAction: (() -> Unit)? = null,
|
||||
result: (flag: Boolean) -> Unit
|
||||
) {
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// 标记进入系统页面
|
||||
SystemPageNavigationController.markEnterStorageAccessPage()
|
||||
|
||||
val intent =
|
||||
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = Uri.parse("package:${packageName}")
|
||||
jumpAction?.invoke()
|
||||
launcher.launch(intent) {
|
||||
// 权限检查完成后,标记离开系统页面
|
||||
SystemPageNavigationController.markLeaveSystemPage()
|
||||
result.invoke(checkStorePermission())
|
||||
}
|
||||
} else {
|
||||
launcher.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { map ->
|
||||
result(map.values.all { it })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.example.base.extention
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.ParameterizedType
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
@JvmName("inflateWithGeneric")
|
||||
fun <VB : ViewBinding> AppCompatActivity.inflateBindingWithGeneric(layoutInflater: LayoutInflater): VB =
|
||||
withGenericBindingClass<VB, Any>(this) { clazz ->
|
||||
clazz.getMethod("inflate", LayoutInflater::class.java).invoke(null, layoutInflater) as VB
|
||||
}.also { binding ->
|
||||
if (binding is ViewDataBinding) {
|
||||
binding.lifecycleOwner = this
|
||||
}
|
||||
}
|
||||
|
||||
@JvmName("inflateWithGeneric")
|
||||
fun <VB : ViewBinding> Fragment.inflateBindingWithGeneric(
|
||||
layoutInflater: LayoutInflater,
|
||||
parent: ViewGroup?,
|
||||
attachToParent: Boolean
|
||||
): VB {
|
||||
return inflateBindingWithGeneric<VB, Any>(null, layoutInflater, parent, attachToParent)
|
||||
}
|
||||
|
||||
fun <VB : ViewBinding, T> Fragment.inflateBindingWithGeneric(
|
||||
targetClass: Class<T>? = null,
|
||||
layoutInflater: LayoutInflater,
|
||||
parent: ViewGroup?,
|
||||
attachToParent: Boolean
|
||||
): VB =
|
||||
withGenericBindingClass<VB, T>(this, targetClass) { clazz ->
|
||||
clazz.getMethod(
|
||||
"inflate",
|
||||
LayoutInflater::class.java,
|
||||
ViewGroup::class.java,
|
||||
Boolean::class.java
|
||||
)
|
||||
.invoke(null, layoutInflater, parent, attachToParent) as VB
|
||||
}.also { binding ->
|
||||
if (binding is ViewDataBinding) {
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
}
|
||||
}
|
||||
|
||||
private fun <VB : ViewBinding, T> withGenericBindingClass(
|
||||
any: Any,
|
||||
targetClass: Class<T>? = null,
|
||||
block: (Class<VB>) -> VB
|
||||
): VB {
|
||||
var genericSuperclass = any::class.java.genericSuperclass
|
||||
var superclass = any::class.java.superclass
|
||||
while (superclass != null) {
|
||||
if (genericSuperclass is ParameterizedType && (targetClass == null || targetClass == superclass)) {
|
||||
try {
|
||||
val type = if (genericSuperclass.actualTypeArguments.size > 1) {
|
||||
genericSuperclass.actualTypeArguments[1]
|
||||
} else {
|
||||
genericSuperclass.actualTypeArguments[0]
|
||||
}
|
||||
return block.invoke(type as Class<VB>)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
} catch (e: ClassCastException) {
|
||||
} catch (e: InvocationTargetException) {
|
||||
throw e.targetException
|
||||
}
|
||||
}
|
||||
genericSuperclass = superclass.genericSuperclass
|
||||
superclass = superclass.superclass
|
||||
}
|
||||
throw IllegalArgumentException("There is no generic of ViewBinding.")
|
||||
}
|
||||
|
||||
inline fun <reified T : ViewBinding> ViewGroup.viewBinding() =
|
||||
ViewBindingDelegate(T::class.java, this)
|
||||
|
||||
class ViewBindingDelegate<T : ViewBinding>(
|
||||
private val bindingClass: Class<T>,
|
||||
val view: ViewGroup
|
||||
) : ReadOnlyProperty<ViewGroup, T> {
|
||||
private var binding: T? = null
|
||||
|
||||
override fun getValue(thisRef: ViewGroup, property: KProperty<*>): T {
|
||||
binding?.let { return it }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
binding = try {
|
||||
val inflateMethod =
|
||||
bindingClass.getMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java)
|
||||
inflateMethod.invoke(null, LayoutInflater.from(thisRef.context), thisRef) as T
|
||||
} catch (e: NoSuchMethodException) {
|
||||
val inflateMethod = bindingClass.getMethod(
|
||||
"inflate",
|
||||
LayoutInflater::class.java,
|
||||
ViewGroup::class.java,
|
||||
Boolean::class.java
|
||||
)
|
||||
inflateMethod.invoke(null, LayoutInflater.from(thisRef.context), thisRef, true) as T
|
||||
}
|
||||
|
||||
return binding ?: throw IllegalStateException("Binding should have been initialized")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
package com.example.base.extention
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
/**
|
||||
* WindowInsets相关扩展函数
|
||||
* 用于处理Android 15边到边显示的适配
|
||||
*/
|
||||
|
||||
/**
|
||||
* WindowInsets缓存单例
|
||||
* 避免重复设置监听器,提高性能
|
||||
*/
|
||||
object WindowInsetsCache {
|
||||
private var cachedSystemBars: Insets? = null
|
||||
private var isInitialized = false
|
||||
private val pendingActions = mutableListOf<(Insets) -> Unit>()
|
||||
|
||||
fun getSystemBars(): Insets? = cachedSystemBars
|
||||
|
||||
fun initIfNeeded(view: View, onReady: ((Insets) -> Unit)? = null) {
|
||||
if (!isInitialized) {
|
||||
// 找到根View来设置监听器
|
||||
val rootView = view.rootView
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, windowInsets ->
|
||||
cachedSystemBars = windowInsets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
)
|
||||
isInitialized = true
|
||||
|
||||
// 执行所有等待的操作
|
||||
executePendingActions()
|
||||
|
||||
windowInsets
|
||||
}
|
||||
ViewCompat.requestApplyInsets(rootView)
|
||||
}
|
||||
|
||||
// 处理回调
|
||||
if (cachedSystemBars != null && onReady != null) {
|
||||
// 已经有缓存值,立即执行
|
||||
onReady(cachedSystemBars!!)
|
||||
} else if (onReady != null) {
|
||||
// 还没有缓存值,加入等待列表
|
||||
pendingActions.add(onReady)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executePendingActions() {
|
||||
cachedSystemBars?.let { systemBars ->
|
||||
pendingActions.forEach { action ->
|
||||
action(systemBars)
|
||||
}
|
||||
pendingActions.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置缓存(主要用于测试或特殊情况)
|
||||
*/
|
||||
fun reset() {
|
||||
cachedSystemBars = null
|
||||
isInitialized = false
|
||||
pendingActions.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为View设置顶部边距以适配状态栏
|
||||
* 直接使用缓存版本实现
|
||||
*/
|
||||
fun View.appendStatusBarMarginTop() {
|
||||
WindowInsetsCache.initIfNeeded(this) { systemBars ->
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin += systemBars.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为View添加底部边距,避免被导航栏遮挡
|
||||
* 用于确保内容不延伸到导航栏下方
|
||||
*/
|
||||
fun View.appendNavigationBarMarginBottom() {
|
||||
WindowInsetsCache.initIfNeeded(this) { systemBars ->
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
bottomMargin += systemBars.bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为View设置顶部Padding以适配状态栏
|
||||
*/
|
||||
fun View.appendStatusBarPaddingTop() {
|
||||
WindowInsetsCache.initIfNeeded(this) { systemBars ->
|
||||
setPadding(paddingLeft, paddingTop + systemBars.top, paddingRight, paddingBottom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为View设置底部Padding以适配导航栏
|
||||
* 用于确保内容不被导航栏遮挡
|
||||
*/
|
||||
fun View.appendNavigationBarPaddingBottom() {
|
||||
WindowInsetsCache.initIfNeeded(this) { systemBars ->
|
||||
setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom + systemBars.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.appendNavigationBarPaddingVertical() {
|
||||
WindowInsetsCache.initIfNeeded(this) { systemBars ->
|
||||
setPadding(
|
||||
paddingLeft,
|
||||
paddingTop + systemBars.top,
|
||||
paddingRight,
|
||||
paddingBottom + systemBars.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态栏外观
|
||||
* @param isDarkFont true=暗色字体(亮色背景), false=亮色字体(暗色背景)
|
||||
*/
|
||||
fun Activity.setStatusBarAppearance(isDarkFont: Boolean) {
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.isAppearanceLightStatusBars = isDarkFont
|
||||
}
|
||||
|
||||
/**
|
||||
* 现代方式:使用WindowInsetsController设置导航栏样式
|
||||
* 推荐使用此方法替代直接设置颜色的过时API
|
||||
* @param isLightBackground true=亮色背景(深色按钮), false=暗色背景(白色按钮)
|
||||
* @param enforceContrast 是否强制系统对比度
|
||||
*/
|
||||
fun Activity.setNavigationBarAppearance(
|
||||
isLightBackground: Boolean = false,
|
||||
enforceContrast: Boolean = true
|
||||
) {
|
||||
val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
windowInsetsController.isAppearanceLightNavigationBars = isLightBackground
|
||||
|
||||
// 设置对比度强制策略
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = enforceContrast
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置导航栏背景颜色 (现代API版本)
|
||||
* 适用于边到边显示模式下的导航栏颜色自定义
|
||||
* @param color 导航栏背景颜色,支持透明度
|
||||
* @param lightButtons true=深色按钮(亮色背景), false=白色按钮(暗色背景)
|
||||
*/
|
||||
fun Activity.setNavigationBarColor(@ColorInt color: Int) {
|
||||
// 使用现代API设置导航栏颜色
|
||||
window.apply {
|
||||
// 确保设置了正确的标志
|
||||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
|
||||
// 设置颜色(尽管已废弃,但仍需要用于兼容性)
|
||||
@Suppress("DEPRECATION")
|
||||
navigationBarColor = color
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置状态栏背景颜色 (现代API版本)
|
||||
* 适用于边到边显示模式下的状态栏颜色自定义
|
||||
* @param color 状态栏背景颜色,支持透明度
|
||||
*/
|
||||
fun Activity.setStatusBarColor(@ColorInt color: Int) {
|
||||
// 使用现代API设置状态栏颜色
|
||||
window.apply {
|
||||
// 确保设置了正确的标志
|
||||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
|
||||
// 设置颜色(尽管已废弃,但仍需要用于兼容性)
|
||||
@Suppress("DEPRECATION")
|
||||
statusBarColor = color
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用边到边显示并设置默认样式
|
||||
* 这是推荐的现代方式,替代直接设置导航栏颜色的过时API
|
||||
*/
|
||||
fun ComponentActivity.edgeToEdge() {
|
||||
enableEdgeToEdge()// 状态栏、导航栏显示内容,并且透明
|
||||
setStatusBarAppearance(isDarkFont = true)
|
||||
setNavigationBarColor(Color.WHITE)
|
||||
setNavigationBarAppearance(isLightBackground = true, enforceContrast = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 让 View 忽略系统 WindowInsets(不自动适配系统栏的 padding)
|
||||
*/
|
||||
fun View.ignoreWindowInsets() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.remax.base.log
|
||||
|
||||
import android.util.Log
|
||||
import com.remax.base.BuildConfig
|
||||
|
||||
/**
|
||||
* Base模块日志工具类
|
||||
* 提供统一的日志输出控制和管理
|
||||
*/
|
||||
object BaseLogger {
|
||||
private const val TAG = "BaseModule"
|
||||
|
||||
/**
|
||||
* 日志开关,默认为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,59 @@
|
|||
package com.remax.base.provider
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import com.remax.base.log.BaseLogger
|
||||
|
||||
/**
|
||||
* Base模块内容提供者
|
||||
* 用于在模块初始化时获取 Context 并提供给其他模块使用
|
||||
* 优先级最高,确保在其他模块初始化之前就准备好
|
||||
*/
|
||||
class BaseModuleProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
private var applicationContext: android.content.Context? = null
|
||||
|
||||
/**
|
||||
* 获取应用上下文
|
||||
* 替代 Utils.getApp() 的依赖
|
||||
*/
|
||||
fun getApplicationContext(): android.content.Context? = applicationContext
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
applicationContext = context?.applicationContext
|
||||
applicationContext?.let { ctx ->
|
||||
try {
|
||||
BaseLogger.d("BaseModuleProvider 初始化完成,Context 已准备就绪")
|
||||
} catch (e: Exception) {
|
||||
BaseLogger.e("BaseModuleProvider 初始化失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): Int = 0
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
package com.remax.base.report
|
||||
|
||||
/**
|
||||
* 数据上报接口
|
||||
* 提供统一的数据上报功能
|
||||
*/
|
||||
interface DataReporter {
|
||||
/**
|
||||
* 获取上报器名称
|
||||
* @return 上报器名称
|
||||
*/
|
||||
fun getName(): String
|
||||
|
||||
/**
|
||||
* 上报数据
|
||||
* @param eventName 事件名称
|
||||
* @param data 数据Map,key为参数名,value为参数值
|
||||
*/
|
||||
fun reportData(eventName: String, data: Map<String, Any>)
|
||||
|
||||
/**
|
||||
* 设置公共参数
|
||||
* @param params 公共参数Map,key为参数名,value为参数值
|
||||
*/
|
||||
fun setCommonParams(params: Map<String, Any>)
|
||||
|
||||
/**
|
||||
* 设置用户参数
|
||||
* @param params 用户参数Map,key为参数名,value为参数值
|
||||
*/
|
||||
fun setUserParams(params: Map<String, Any>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据上报管理器
|
||||
* 管理数据上报器的注入和调用
|
||||
*/
|
||||
object DataReportManager {
|
||||
private var reporters: MutableList<DataReporter> = mutableListOf()
|
||||
|
||||
/**
|
||||
* 设置数据上报器集合
|
||||
* @param reporters 数据上报器实现集合
|
||||
*/
|
||||
fun setReporters(reporters: Collection<DataReporter>) {
|
||||
this.reporters.clear()
|
||||
this.reporters.addAll(reporters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置公共参数
|
||||
* @param params 公共参数Map
|
||||
*/
|
||||
fun setCommonParams(params: Map<String, Any>) {
|
||||
// 同时设置所有上报器的公共参数
|
||||
reporters.forEach { reporter ->
|
||||
try {
|
||||
reporter.setCommonParams(params)
|
||||
} catch (e: Exception) {
|
||||
// 单个上报器失败不影响其他上报器
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户参数
|
||||
* @param params 用户参数Map
|
||||
*/
|
||||
fun setUserParams(params: Map<String, Any>) {
|
||||
// 同时设置所有上报器的用户参数
|
||||
reporters.forEach { reporter ->
|
||||
try {
|
||||
reporter.setUserParams(params)
|
||||
} catch (e: Exception) {
|
||||
// 单个上报器失败不影响其他上报器
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报数据(自动合并公共参数和用户参数)
|
||||
* @param eventName 事件名称
|
||||
* @param data 数据Map
|
||||
*/
|
||||
fun reportData(eventName: String, data: Map<String, Any>) {
|
||||
try {
|
||||
// 遍历所有上报器进行上报
|
||||
reporters.forEach { reporter ->
|
||||
try {
|
||||
reporter.reportData(eventName, data)
|
||||
} catch (e: Exception) {
|
||||
// 单个上报器失败不影响其他上报器
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 静默处理异常,避免影响主流程
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称上报数据到指定上报器
|
||||
* @param reporterName 上报器名称
|
||||
* @param eventName 事件名称
|
||||
* @param data 数据Map
|
||||
*/
|
||||
fun reportDataByName(reporterName: String, eventName: String, data: Map<String, Any>) {
|
||||
try {
|
||||
// 查找指定名称的上报器
|
||||
val targetReporter = reporters.find { it.getName() == reporterName }
|
||||
if (targetReporter != null) {
|
||||
try {
|
||||
targetReporter.reportData(eventName, data)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 静默处理异常,避免影响主流程
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
* @return 是否已设置上报器
|
||||
*/
|
||||
fun isInitialized(): Boolean {
|
||||
return reporters.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.remax.base.temlate
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.blankj.utilcode.util.LanguageUtils
|
||||
import com.example.base.extention.appendNavigationBarPaddingBottom
|
||||
import com.example.base.extention.edgeToEdge
|
||||
import com.remax.base.report.DataReportManager
|
||||
|
||||
abstract class BaseAct : AppCompatActivity() {
|
||||
|
||||
|
||||
abstract fun xGetIdXml(): Int
|
||||
|
||||
abstract fun xLoadViewFromXml(savedInstanceState: Bundle?)
|
||||
|
||||
open fun prepareData() {
|
||||
|
||||
}
|
||||
|
||||
open var isSaveInstance = true
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
isSaveInstance = true
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
isSaveInstance = false
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
isSaveInstance = false
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
edgeToEdge()
|
||||
|
||||
supportActionBar?.hide()
|
||||
|
||||
prepareBindView()?.let {
|
||||
setContentView(it)
|
||||
} ?: run {
|
||||
setContentView(xGetIdXml())
|
||||
}
|
||||
runTemp(savedInstanceState)
|
||||
}
|
||||
|
||||
|
||||
private fun runTemp(savedInstanceState: Bundle?) {
|
||||
|
||||
//函数抽取
|
||||
xLoadViewFromXml(savedInstanceState)
|
||||
prepareData()
|
||||
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
DataReportManager.reportDataByName("ThinkingData","pageView",mapOf(
|
||||
"pageName" to this::class.java.simpleName,
|
||||
"pageEvent" to "onStart"))
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
DataReportManager.reportDataByName("ThinkingData","pageView",mapOf(
|
||||
"pageName" to this::class.java.simpleName,
|
||||
"pageEvent" to "onStop"))
|
||||
}
|
||||
|
||||
|
||||
open fun prepareBindView(): View? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun replaceFragment(layoutId: Int, fragment: Fragment?) {
|
||||
if (fragment == null) return
|
||||
val fragmentManager = supportFragmentManager
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
transaction.replace(layoutId, fragment)
|
||||
transaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED)
|
||||
.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
open fun showFragment(layoutId: Int, fragment: Fragment?) {
|
||||
if (fragment == null) return
|
||||
val fragmentManager = supportFragmentManager
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
if (!fragment.isAdded) {
|
||||
transaction.add(layoutId, fragment)
|
||||
} else {
|
||||
transaction.show(fragment)
|
||||
}
|
||||
transaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED)
|
||||
.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
open fun showFragment(fragment: Fragment?) {
|
||||
if (fragment == null) return
|
||||
val fragmentManager = supportFragmentManager
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
transaction.show(fragment)
|
||||
.setMaxLifecycle(fragment, Lifecycle.State.RESUMED)
|
||||
.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
open fun hideFragment(fragment: Fragment?) {
|
||||
if (fragment == null) return
|
||||
val fragmentManager = supportFragmentManager
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
transaction.hide(fragment)
|
||||
.setMaxLifecycle(fragment, Lifecycle.State.STARTED)
|
||||
.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
|
||||
open fun removeFragment(fragment: Fragment?) {
|
||||
if (fragment == null || !fragment.isAdded) return
|
||||
val fragmentManager = supportFragmentManager
|
||||
val transaction = fragmentManager.beginTransaction()
|
||||
transaction.remove(fragment).commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(LanguageUtils.attachBaseContext(newBase))
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.remax.base.temlate
|
||||
|
||||
import android.view.View
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.example.base.extention.inflateBindingWithGeneric
|
||||
|
||||
abstract class BaseBindAct<VB : ViewBinding> : BaseAct() {
|
||||
|
||||
override fun xGetIdXml(): Int = 0
|
||||
|
||||
lateinit var selfBindView: VB
|
||||
|
||||
override fun prepareBindView(): View? {
|
||||
selfBindView = inflateBindingWithGeneric(layoutInflater)
|
||||
return selfBindView.root
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.remax.base.temlate
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.example.base.extention.inflateBindingWithGeneric
|
||||
|
||||
abstract class BaseBindFrag<VB : ViewBinding> : BaseFrag() {
|
||||
|
||||
override fun xmlIdView() = 0
|
||||
|
||||
private var _binding: VB? = null
|
||||
val selfBindView: VB? get() = _binding
|
||||
|
||||
val mViewBindNoNull: VB get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
_binding = inflateBindingWithGeneric(inflater, container, false)
|
||||
return selfBindView?.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
fun isCreated(): Boolean {
|
||||
return _binding != null
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.remax.base.temlate
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
abstract class BaseFrag : Fragment() {
|
||||
abstract fun xmlIdView(): Int
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(xmlIdView(), container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
//函数抽取
|
||||
onReadView(savedInstanceState)
|
||||
|
||||
}
|
||||
|
||||
|
||||
/** 初始化view */
|
||||
abstract fun onReadView(savedInstanceState: Bundle?)
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
loadDelay()
|
||||
}
|
||||
|
||||
private var inflated = false
|
||||
|
||||
private fun loadDelay() {
|
||||
if (inflated) {
|
||||
return
|
||||
}
|
||||
inflated = true
|
||||
viewOnInitForDelay()
|
||||
}
|
||||
|
||||
open fun viewOnInitForDelay() {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.ActivityResultCaller
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
||||
class ActivityLauncher(activityResultCaller: ActivityResultCaller) {
|
||||
|
||||
//region 权限
|
||||
private var permissionCallback: ActivityResultCallback<Map<String, Boolean>>? = null
|
||||
private val permissionLauncher =
|
||||
activityResultCaller.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result: Map<String, Boolean> ->
|
||||
permissionCallback?.onActivityResult(result)
|
||||
}
|
||||
|
||||
fun launch(
|
||||
permissionArray: Array<String>,
|
||||
permissionCallback: ActivityResultCallback<Map<String, Boolean>>?
|
||||
) {
|
||||
this.permissionCallback = permissionCallback
|
||||
permissionLauncher.launch(permissionArray)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region intent跳转
|
||||
private var activityResultCallback: ActivityResultCallback<ActivityResult>? = null
|
||||
private val intentLauncher =
|
||||
activityResultCaller.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult: ActivityResult ->
|
||||
activityResultCallback?.onActivityResult(activityResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* it.resultCode == Activity.RESULT_OK
|
||||
*/
|
||||
fun launch(
|
||||
intent: Intent,
|
||||
activityResultCallback: ActivityResultCallback<ActivityResult>? = null
|
||||
) {
|
||||
this.activityResultCallback = activityResultCallback
|
||||
intentLauncher.launch(intent)
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
||||
//region saf
|
||||
// private var safResultCallback: ActivityResultCallback<Uri?>? = null
|
||||
// private val safLauncher =
|
||||
// activityResultCaller.registerForActivityResult(
|
||||
// ActivityResultContracts.OpenDocument(),
|
||||
// ) { uri ->
|
||||
// safResultCallback?.onActivityResult(uri)
|
||||
// }
|
||||
//
|
||||
// fun launch(array: Array<String>, safResultCallback: ActivityResultCallback<Uri?>?) {
|
||||
// this.safResultCallback = safResultCallback
|
||||
// safLauncher.launch(array)
|
||||
// }
|
||||
//end region
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.base.provider.BaseModuleProvider
|
||||
import com.remax.base.log.BaseLogger
|
||||
|
||||
/**
|
||||
* Context 提供者工具类
|
||||
* 统一管理应用上下文的获取,避免直接依赖 Utils.getApp()
|
||||
*/
|
||||
object ContextProvider {
|
||||
|
||||
/**
|
||||
* 获取应用上下文
|
||||
* 优先使用 BaseModuleProvider,如果未初始化则尝试其他方式
|
||||
* @return 应用上下文,如果获取失败则抛出异常
|
||||
*/
|
||||
fun getAppContext(): Context {
|
||||
// 优先使用 BaseModuleProvider
|
||||
BaseModuleProvider.getApplicationContext()?.let { context ->
|
||||
return context
|
||||
}
|
||||
|
||||
// 如果 BaseModuleProvider 还未初始化,记录警告并抛出异常
|
||||
BaseLogger.w("BaseModuleProvider 尚未初始化,无法获取应用上下文")
|
||||
throw IllegalStateException("BaseModuleProvider 尚未初始化,请确保 ContentProvider 已正确注册")
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取应用上下文
|
||||
* 如果获取失败返回 null,不会抛出异常
|
||||
* @return 应用上下文,如果获取失败返回 null
|
||||
*/
|
||||
fun getAppContextOrNull(): Context? {
|
||||
return try {
|
||||
getAppContext()
|
||||
} catch (e: Exception) {
|
||||
BaseLogger.e("获取应用上下文失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Context 是否已准备就绪
|
||||
* @return true 如果 Context 可用,false 如果不可用
|
||||
*/
|
||||
fun isContextReady(): Boolean {
|
||||
return BaseModuleProvider.getApplicationContext() != null
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,164 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue
|
||||
import android.widget.ImageView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
|
||||
/**
|
||||
* 图片加载工具类
|
||||
* 基于Glide封装,提供常用的图片加载方法
|
||||
*/
|
||||
object ImageLoader {
|
||||
|
||||
/**
|
||||
* 加载图片到ImageView
|
||||
* @param context 上下文
|
||||
* @param imageView 目标ImageView
|
||||
* @param url 图片URL或路径
|
||||
* @param placeholder 占位图资源ID
|
||||
* @param error 错误图资源ID
|
||||
* @param cornerRadius 圆角半径,单位dp,默认0表示无圆角
|
||||
*/
|
||||
fun loadImage(
|
||||
context: Context,
|
||||
imageView: ImageView,
|
||||
url: String?,
|
||||
placeholder: Int = 0,
|
||||
error: Int = 0,
|
||||
cornerRadius: Float = 0f
|
||||
) {
|
||||
val requestOptions = RequestOptions()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.centerCrop()
|
||||
|
||||
if (placeholder != 0) {
|
||||
requestOptions.placeholder(placeholder)
|
||||
}
|
||||
if (error != 0) {
|
||||
requestOptions.error(error)
|
||||
}
|
||||
|
||||
val requestBuilder = Glide.with(context)
|
||||
.load(url)
|
||||
.apply(requestOptions)
|
||||
|
||||
// 如果设置了圆角,应用圆角变换
|
||||
if (cornerRadius > 0) {
|
||||
val radiusInPixels = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
cornerRadius,
|
||||
context.resources.displayMetrics
|
||||
)
|
||||
|
||||
requestBuilder.transform(RoundedCornersTransformation(radiusInPixels))
|
||||
}
|
||||
|
||||
requestBuilder.into(imageView)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载本地文件图片
|
||||
* @param context 上下文
|
||||
* @param imageView 目标ImageView
|
||||
* @param filePath 本地文件路径
|
||||
* @param placeholder 占位图资源ID
|
||||
* @param error 错误图资源ID
|
||||
* @param cornerRadius 圆角半径,单位dp,默认0表示无圆角
|
||||
*/
|
||||
fun loadLocalImage(
|
||||
context: Context,
|
||||
imageView: ImageView,
|
||||
filePath: String?,
|
||||
placeholder: Int = 0,
|
||||
error: Int = 0,
|
||||
cornerRadius: Float = 0f
|
||||
) {
|
||||
val requestOptions = RequestOptions()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.centerCrop()
|
||||
|
||||
if (placeholder != 0) {
|
||||
requestOptions.placeholder(placeholder)
|
||||
}
|
||||
if (error != 0) {
|
||||
requestOptions.error(error)
|
||||
}
|
||||
|
||||
val requestBuilder = Glide.with(context)
|
||||
.load(filePath)
|
||||
.apply(requestOptions)
|
||||
|
||||
// 如果设置了圆角,应用圆角变换
|
||||
if (cornerRadius > 0) {
|
||||
val radiusInPixels = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
cornerRadius,
|
||||
context.resources.displayMetrics
|
||||
)
|
||||
|
||||
requestBuilder.transform(RoundedCornersTransformation(radiusInPixels))
|
||||
}
|
||||
|
||||
requestBuilder.into(imageView)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载圆形图片
|
||||
* @param context 上下文
|
||||
* @param imageView 目标ImageView
|
||||
* @param url 图片URL或路径
|
||||
* @param placeholder 占位图资源ID
|
||||
* @param error 错误图资源ID
|
||||
*/
|
||||
fun loadCircleImage(
|
||||
context: Context,
|
||||
imageView: ImageView,
|
||||
url: String?,
|
||||
placeholder: Int = 0,
|
||||
error: Int = 0
|
||||
) {
|
||||
val requestOptions = RequestOptions()
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.circleCrop()
|
||||
|
||||
if (placeholder != 0) {
|
||||
requestOptions.placeholder(placeholder)
|
||||
}
|
||||
if (error != 0) {
|
||||
requestOptions.error(error)
|
||||
}
|
||||
|
||||
Glide.with(context)
|
||||
.load(url)
|
||||
.apply(requestOptions)
|
||||
.into(imageView)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除内存缓存
|
||||
* @param context 上下文
|
||||
*/
|
||||
fun clearMemoryCache(context: Context) {
|
||||
Glide.get(context).clearMemory()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除磁盘缓存
|
||||
* @param context 上下文
|
||||
*/
|
||||
fun clearDiskCache(context: Context) {
|
||||
Glide.get(context).clearDiskCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
* @param context 上下文
|
||||
*/
|
||||
fun clearAllCache(context: Context) {
|
||||
clearMemoryCache(context)
|
||||
clearDiskCache(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.example.features.setting.utils
|
||||
|
||||
import com.blankj.utilcode.util.LanguageUtils
|
||||
import java.util.Locale
|
||||
|
||||
class LanguageController private constructor() {
|
||||
|
||||
companion object {
|
||||
const val ENGLISH = "en" // 英语
|
||||
const val SPANISH = "es" // 西班牙语
|
||||
const val PORTUGUESE = "pt" // 葡萄牙语
|
||||
const val KOREAN = "kr" // 韩语
|
||||
const val JAPANESE = "jp" // 日语
|
||||
const val FRENCH = "fr" // 法语
|
||||
const val GERMAN = "de" // 德语
|
||||
const val TURKISH = "tr" // 土耳其语
|
||||
const val RUSSIAN = "ru" // 俄语
|
||||
const val CHINESE_TW = "zh_tw" // 繁体中文
|
||||
const val CHINESE_CN = "zh_cn" // 简体中文
|
||||
const val THAI = "th" // 泰语
|
||||
const val VIETNAMESE = "vn" // 越南语
|
||||
const val ARABIC = "arb" // 阿拉伯语
|
||||
const val INDONESIAN = "id" // 印尼语
|
||||
const val ITALIAN = "it" // 意大利语
|
||||
const val DANISH = "da" // 丹麦语
|
||||
const val PERSIAN = "fa" // 波斯语
|
||||
const val SWEDISH = "sv" // 瑞典语
|
||||
|
||||
private val languageSampleMap: Map<String, String> = mapOf(
|
||||
ENGLISH to "English",
|
||||
SPANISH to "Español",
|
||||
PORTUGUESE to "Português",
|
||||
KOREAN to "한국어",
|
||||
JAPANESE to "日本語",
|
||||
FRENCH to "Français",
|
||||
GERMAN to "Deutsch",
|
||||
TURKISH to "Türkçe",
|
||||
RUSSIAN to "Русский",
|
||||
CHINESE_TW to "中文繁體",
|
||||
CHINESE_CN to "中文简体",
|
||||
THAI to "ไทย",
|
||||
VIETNAMESE to "Tiếng Việt",
|
||||
ARABIC to "العربية",
|
||||
INDONESIAN to "Bahasa Indonesia",
|
||||
ITALIAN to "Italiano",
|
||||
DANISH to "Dansk",
|
||||
PERSIAN to "فارسی",
|
||||
SWEDISH to "Svenska"
|
||||
)
|
||||
|
||||
|
||||
private val localeMap: Map<String, Locale> = mapOf(
|
||||
ENGLISH to Locale("en"), // 英语
|
||||
SPANISH to Locale("es"), // 西班牙语
|
||||
PORTUGUESE to Locale("pt", "BR"), // 葡萄牙语(巴西)
|
||||
KOREAN to Locale("ko", "KR"), // 韩语(韩国)
|
||||
JAPANESE to Locale("ja", "JP"), // 日语(日本)
|
||||
FRENCH to Locale("fr"), // 法语
|
||||
GERMAN to Locale("de"), // 德语
|
||||
TURKISH to Locale("tr"), // 土耳其语
|
||||
RUSSIAN to Locale("ru"), // 俄语
|
||||
CHINESE_TW to Locale("zh", "TW"), // 台湾繁体中文
|
||||
CHINESE_CN to Locale("zh", "CN"), // 简体中文
|
||||
THAI to Locale("th"), // 泰语
|
||||
VIETNAMESE to Locale("vi"), // 越南语
|
||||
ARABIC to Locale("ar"), // 阿拉伯语
|
||||
INDONESIAN to Locale("id"), // 印尼语
|
||||
ITALIAN to Locale("it"), // 意大利语
|
||||
DANISH to Locale("da"), // 丹麦语
|
||||
PERSIAN to Locale("fa"), // 波斯语
|
||||
SWEDISH to Locale("sv"), // 瑞典语
|
||||
)
|
||||
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: LanguageController? = null
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
fun getInstance(): LanguageController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: LanguageController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apply(aliens: String) {
|
||||
localeMap[aliens]?.let {
|
||||
LanguageUtils.applyLanguage(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAliens(): String {
|
||||
val locale = LanguageUtils.getAppliedLanguage() ?: LanguageUtils.getAppContextLanguage()
|
||||
val key = localeMap.entries.find { entry ->
|
||||
val mapLocale = entry.value
|
||||
// 匹配语言代码和国家代码
|
||||
mapLocale.language == locale.language &&
|
||||
(mapLocale.country.isEmpty() || mapLocale.country == locale.country)
|
||||
}?.key
|
||||
return key ?: ENGLISH
|
||||
}
|
||||
|
||||
fun sample(aliens: String = getAliens()): String {
|
||||
return languageSampleMap[aliens] ?: languageSampleMap.values.first()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.airbnb.lottie.LottieComposition
|
||||
import com.airbnb.lottie.LottieCompositionFactory
|
||||
import com.airbnb.lottie.LottieTask
|
||||
|
||||
/**
|
||||
* Lottie动画工具类
|
||||
*/
|
||||
object LottieUtils {
|
||||
|
||||
/**
|
||||
* 从资源文件加载Lottie动画
|
||||
* @param context 上下文
|
||||
* @param rawResId 资源ID
|
||||
* @param callback 加载回调
|
||||
*/
|
||||
fun loadAnimationFromRaw(
|
||||
context: Context,
|
||||
rawResId: Int,
|
||||
callback: (LottieComposition?) -> Unit
|
||||
) {
|
||||
LottieCompositionFactory.fromRawRes(context, rawResId)
|
||||
.addListener { composition ->
|
||||
callback(composition)
|
||||
}
|
||||
.addFailureListener { exception ->
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Assets文件夹加载Lottie动画
|
||||
* @param context 上下文
|
||||
* @param fileName 文件名
|
||||
* @param callback 加载回调
|
||||
*/
|
||||
fun loadAnimationFromAssets(
|
||||
context: Context,
|
||||
fileName: String,
|
||||
callback: (LottieComposition?) -> Unit
|
||||
) {
|
||||
LottieCompositionFactory.fromAsset(context, fileName)
|
||||
.addListener { composition ->
|
||||
callback(composition)
|
||||
}
|
||||
.addFailureListener { exception ->
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从网络URL加载Lottie动画
|
||||
* @param context 上下文
|
||||
* @param url 动画URL
|
||||
* @param callback 加载回调
|
||||
*/
|
||||
fun loadAnimationFromUrl(
|
||||
context: Context,
|
||||
url: String,
|
||||
callback: (LottieComposition?) -> Unit
|
||||
) {
|
||||
LottieCompositionFactory.fromUrl(context, url)
|
||||
.addListener { composition ->
|
||||
callback(composition)
|
||||
}
|
||||
.addFailureListener { exception ->
|
||||
callback(null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置LottieAnimationView的动画
|
||||
* @param lottieView Lottie动画视图
|
||||
* @param rawResId 资源ID
|
||||
* @param autoPlay 是否自动播放
|
||||
* @param repeatCount 重复次数,-1为无限循环
|
||||
*/
|
||||
fun setAnimation(
|
||||
lottieView: LottieAnimationView,
|
||||
rawResId: Int,
|
||||
autoPlay: Boolean = true,
|
||||
repeatCount: Int = -1
|
||||
) {
|
||||
lottieView.setAnimation(rawResId)
|
||||
lottieView.repeatCount = repeatCount
|
||||
if (autoPlay) {
|
||||
lottieView.playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置LottieAnimationView的动画(从Assets)
|
||||
* @param lottieView Lottie动画视图
|
||||
* @param fileName 文件名
|
||||
* @param autoPlay 是否自动播放
|
||||
* @param repeatCount 重复次数,-1为无限循环
|
||||
*/
|
||||
fun setAnimationFromAssets(
|
||||
lottieView: LottieAnimationView,
|
||||
fileName: String,
|
||||
autoPlay: Boolean = true,
|
||||
repeatCount: Int = -1
|
||||
) {
|
||||
lottieView.setAnimation(fileName)
|
||||
lottieView.repeatCount = repeatCount
|
||||
if (autoPlay) {
|
||||
lottieView.playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置LottieAnimationView的动画(从URL)
|
||||
* @param lottieView Lottie动画视图
|
||||
* @param url 动画URL
|
||||
* @param autoPlay 是否自动播放
|
||||
* @param repeatCount 重复次数,-1为无限循环
|
||||
*/
|
||||
fun setAnimationFromUrl(
|
||||
lottieView: LottieAnimationView,
|
||||
url: String,
|
||||
autoPlay: Boolean = true,
|
||||
repeatCount: Int = -1
|
||||
) {
|
||||
lottieView.setAnimationFromUrl(url)
|
||||
lottieView.repeatCount = repeatCount
|
||||
if (autoPlay) {
|
||||
lottieView.playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载中的Lottie动画视图
|
||||
* @param context 上下文
|
||||
* @param rawResId 资源ID
|
||||
* @return LottieAnimationView
|
||||
*/
|
||||
fun createLoadingView(
|
||||
context: Context,
|
||||
rawResId: Int
|
||||
): LottieAnimationView {
|
||||
return LottieAnimationView(context).apply {
|
||||
setAnimation(rawResId)
|
||||
repeatCount = -1
|
||||
playAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示/隐藏Lottie动画
|
||||
* @param lottieView Lottie动画视图
|
||||
* @param show 是否显示
|
||||
*/
|
||||
fun showLottieAnimation(lottieView: LottieAnimationView, show: Boolean) {
|
||||
if (show) {
|
||||
lottieView.visibility = View.VISIBLE
|
||||
if (!lottieView.isAnimating) {
|
||||
lottieView.playAnimation()
|
||||
}
|
||||
} else {
|
||||
lottieView.visibility = View.GONE
|
||||
lottieView.pauseAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
|
||||
import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
|
||||
import com.remax.base.BuildConfig
|
||||
import com.remax.base.log.BaseLogger
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Firebase Remote Config 管理器
|
||||
* 负责远程配置的初始化和提供 FirebaseRemoteConfig 实例
|
||||
*/
|
||||
object RemoteConfigManager {
|
||||
private const val TAG = "AdModule"
|
||||
private const val MINIMUM_FETCH_INTERVAL = 3600L // 1小时最小获取间隔
|
||||
private const val WAIT_INTERVAL = 100L // 等待间隔100ms
|
||||
private const val MAX_WAIT_CYCLES = 300 // 最大等待循环次数 (30秒)
|
||||
|
||||
private lateinit var firebaseRemoteConfig: FirebaseRemoteConfig
|
||||
private var isInitialized = false
|
||||
|
||||
/**
|
||||
* 独立的初始化函数
|
||||
*/
|
||||
fun initialize() {
|
||||
if (isInitialized) {
|
||||
logDebug("Remote Config 已经初始化")
|
||||
return
|
||||
}
|
||||
|
||||
logDebug("开始初始化 Remote Config")
|
||||
|
||||
try {
|
||||
// 初始化 Firebase Remote Config
|
||||
firebaseRemoteConfig = FirebaseRemoteConfig.getInstance()
|
||||
|
||||
// 配置 Remote Config 设置
|
||||
val configSettings = FirebaseRemoteConfigSettings.Builder()
|
||||
.setMinimumFetchIntervalInSeconds(MINIMUM_FETCH_INTERVAL)
|
||||
.setFetchTimeoutInSeconds(60) // 60秒超时
|
||||
.build()
|
||||
firebaseRemoteConfig.setConfigSettingsAsync(configSettings)
|
||||
|
||||
// 获取并激活配置
|
||||
firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
isInitialized = true
|
||||
logDebug("Remote Config 初始化成功")
|
||||
} else {
|
||||
logError("Remote Config 初始化失败", task.exception)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logError("Remote Config 初始化异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待初始化完成(用于获取配置值时)
|
||||
* @return true 如果初始化成功,false 如果超时
|
||||
*/
|
||||
private suspend fun waitForInitialization(): Boolean {
|
||||
var waitCycles = 0
|
||||
|
||||
while (!isInitialized && waitCycles < MAX_WAIT_CYCLES) {
|
||||
delay(WAIT_INTERVAL)
|
||||
waitCycles++
|
||||
}
|
||||
|
||||
if (waitCycles >= MAX_WAIT_CYCLES) {
|
||||
logError("等待初始化超时,已等待 ${MAX_WAIT_CYCLES * WAIT_INTERVAL}ms")
|
||||
return false
|
||||
}
|
||||
|
||||
return isInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字符串配置值
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @return 配置值,如果初始化超时则返回 null
|
||||
*/
|
||||
suspend fun getString(key: String, defaultValue: String = ""): String? {
|
||||
if (!waitForInitialization()) {
|
||||
logError("初始化超时,无法获取配置 $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val value = firebaseRemoteConfig.getString(key)
|
||||
if (value.isNotEmpty()) {
|
||||
logDebug("获取配置 $key: '$value'")
|
||||
value
|
||||
} else {
|
||||
logDebug("配置 $key 为空,返回默认值: '$defaultValue'")
|
||||
defaultValue
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError("获取配置 $key 异常", e)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取布尔配置值
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @return 配置值,如果初始化超时则返回 null
|
||||
*/
|
||||
suspend fun getBoolean(key: String, defaultValue: Boolean = false): Boolean? {
|
||||
if (!waitForInitialization()) {
|
||||
logError("初始化超时,无法获取配置 $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val value = firebaseRemoteConfig.getBoolean(key)
|
||||
logDebug("获取配置 $key: $value")
|
||||
value
|
||||
} catch (e: Exception) {
|
||||
logError("获取配置 $key 异常", e)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取整数配置值
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @return 配置值,如果初始化超时则返回 null
|
||||
*/
|
||||
suspend fun getInt(key: String, defaultValue: Int = 0): Int? {
|
||||
if (!waitForInitialization()) {
|
||||
logError("初始化超时,无法获取配置 $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val value = firebaseRemoteConfig.getLong(key).toInt()
|
||||
logDebug("获取配置 $key: $value")
|
||||
value
|
||||
} catch (e: Exception) {
|
||||
logError("获取配置 $key 异常", e)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取长整数配置值
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @return 配置值,如果初始化超时则返回 null
|
||||
*/
|
||||
suspend fun getLong(key: String, defaultValue: Long = 0L): Long? {
|
||||
if (!waitForInitialization()) {
|
||||
logError("初始化超时,无法获取配置 $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val value = firebaseRemoteConfig.getLong(key)
|
||||
logDebug("获取配置 $key: $value")
|
||||
value
|
||||
} catch (e: Exception) {
|
||||
logError("获取配置 $key 异常", e)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取双精度浮点数配置值
|
||||
* @param key 配置键
|
||||
* @param defaultValue 默认值
|
||||
* @return 配置值,如果初始化超时则返回 null
|
||||
*/
|
||||
suspend fun getDouble(key: String, defaultValue: Double = 0.0): Double? {
|
||||
if (!waitForInitialization()) {
|
||||
logError("初始化超时,无法获取配置 $key")
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val value = firebaseRemoteConfig.getDouble(key)
|
||||
logDebug("获取配置 $key: $value")
|
||||
value
|
||||
} catch (e: Exception) {
|
||||
logError("获取配置 $key 异常", e)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新配置
|
||||
* @return 是否刷新成功
|
||||
*/
|
||||
suspend fun refresh(): Boolean = suspendCancellableCoroutine { continuation ->
|
||||
if (!isInitialized) {
|
||||
logWarning("Remote Config 未初始化,无法刷新")
|
||||
continuation.resume(false)
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
logDebug("开始刷新 Remote Config")
|
||||
|
||||
// 临时设置更短的获取间隔
|
||||
val settings = FirebaseRemoteConfigSettings.Builder()
|
||||
.setMinimumFetchIntervalInSeconds(0)
|
||||
.setFetchTimeoutInSeconds(60)
|
||||
.build()
|
||||
firebaseRemoteConfig.setConfigSettingsAsync(settings)
|
||||
|
||||
firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
logDebug("Remote Config 刷新成功")
|
||||
continuation.resume(true)
|
||||
} else {
|
||||
logError("Remote Config 刷新失败", task.exception)
|
||||
continuation.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
fun isInitialized(): Boolean = isInitialized
|
||||
|
||||
/**
|
||||
* 获取 FirebaseRemoteConfig 实例(兼容旧代码)
|
||||
* @param context 上下文
|
||||
* @return FirebaseRemoteConfig 实例,如果未初始化则返回 null
|
||||
*/
|
||||
fun getFirebaseRemoteConfig(context: Context): FirebaseRemoteConfig? {
|
||||
return if (isInitialized) firebaseRemoteConfig else null
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志输出方法
|
||||
*/
|
||||
private fun logDebug(message: String) {
|
||||
if(BaseLogger.isLogEnabled()){
|
||||
Log.d(TAG, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logWarning(message: String) {
|
||||
if(BaseLogger.isLogEnabled()){
|
||||
Log.w(TAG, message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun logError(message: String, throwable: Throwable? = null) {
|
||||
if(BaseLogger.isLogEnabled()){
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, message, throwable)
|
||||
} else {
|
||||
Log.e(TAG, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.remax.base.utils
|
||||
|
||||
import android.graphics.*
|
||||
import androidx.annotation.NonNull
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* 自定义圆角变换
|
||||
* 兼容Glide v4
|
||||
*/
|
||||
class RoundedCornersTransformation(
|
||||
private val cornerRadius: Float
|
||||
) : BitmapTransformation() {
|
||||
|
||||
companion object {
|
||||
private const val ID = "com.remax.base.utils.RoundedCornersTransformation"
|
||||
private val ID_BYTES = ID.toByteArray(CHARSET)
|
||||
}
|
||||
|
||||
override fun transform(
|
||||
@NonNull pool: BitmapPool,
|
||||
@NonNull toTransform: Bitmap,
|
||||
outWidth: Int,
|
||||
outHeight: Int
|
||||
): Bitmap {
|
||||
val bitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
|
||||
if (bitmap == null) {
|
||||
return Bitmap.createBitmap(outWidth, outHeight, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint()
|
||||
paint.isAntiAlias = true
|
||||
paint.shader = BitmapShader(toTransform, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
|
||||
val rect = RectF(0f, 0f, outWidth.toFloat(), outHeight.toFloat())
|
||||
canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is RoundedCornersTransformation && other.cornerRadius == cornerRadius
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return ID.hashCode() + (cornerRadius * 1000).toInt()
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(@NonNull messageDigest: MessageDigest) {
|
||||
messageDigest.update(ID_BYTES)
|
||||
val radiusBytes = cornerRadius.toString().toByteArray(CHARSET)
|
||||
messageDigest.update(radiusBytes)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.remax.base.view
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* RecyclerView网格布局间距装饰器
|
||||
* @param spanCount 列数
|
||||
* @param spacing 间距,单位dp
|
||||
* @param includeEdge 是否包含边缘间距
|
||||
* @param shouldSkipItem 判断是否跳过该item的函数
|
||||
*/
|
||||
open class GridSpacingItemDecoration(
|
||||
private val spanCount: Int,
|
||||
private val spacing: Int,
|
||||
private val includeEdge: Boolean = true,
|
||||
private val shouldSkipItem: ((Int) -> Boolean)? = null
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
// 缓存实际位置映射,避免重复计算
|
||||
private val actualPositionCache = mutableMapOf<Int, Int>()
|
||||
|
||||
override fun onDraw(c: android.graphics.Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDraw(c, parent, state)
|
||||
// 当数据变化时清除缓存
|
||||
actualPositionCache.clear()
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
|
||||
// 检查是否应该跳过该item
|
||||
if (shouldSkipItem?.invoke(position) == true) {
|
||||
// 对于跳过的item,设置为全宽,不设置上下间距
|
||||
outRect.left = 0
|
||||
outRect.right = 0
|
||||
outRect.top = 0
|
||||
outRect.bottom = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 计算实际的列位置(排除跳过的item)
|
||||
val actualPosition = calculateActualPosition(position, parent)
|
||||
val column = actualPosition % spanCount
|
||||
val row = actualPosition / spanCount
|
||||
|
||||
if (includeEdge) {
|
||||
// 包含边缘间距
|
||||
outRect.left = spacing - column * spacing / spanCount
|
||||
outRect.right = (column + 1) * spacing / spanCount
|
||||
|
||||
if (row == 0) {
|
||||
outRect.top = spacing
|
||||
}
|
||||
outRect.bottom = spacing
|
||||
} else {
|
||||
// 不包含边缘间距
|
||||
outRect.left = column * spacing / spanCount
|
||||
outRect.right = spacing - (column + 1) * spacing / spanCount
|
||||
|
||||
if (row > 0) {
|
||||
outRect.top = spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算排除跳过item后的实际位置
|
||||
*/
|
||||
private fun calculateActualPosition(position: Int, parent: RecyclerView): Int {
|
||||
if (shouldSkipItem == null) {
|
||||
return position
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
actualPositionCache[position]?.let { return it }
|
||||
|
||||
var actualPosition = 0
|
||||
for (i in 0 until position) {
|
||||
if (!shouldSkipItem.invoke(i)) {
|
||||
actualPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
actualPositionCache[position] = actualPosition
|
||||
return actualPosition
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
package com.remax.base.view
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* RecyclerView线性布局分割线装饰器
|
||||
* @param dividerHeight 分割线高度,单位dp
|
||||
* @param dividerColor 分割线颜色
|
||||
* @param marginStart 分割线左边距,单位dp
|
||||
* @param marginEnd 分割线右边距,单位dp
|
||||
* @param shouldSkipItem 判断是否跳过该item的函数
|
||||
*/
|
||||
open class LinearDividerItemDecoration(
|
||||
private val dividerHeight: Int,
|
||||
private val dividerColor: Int,
|
||||
private val marginStart: Int = 0,
|
||||
private val marginEnd: Int = 0,
|
||||
private val shouldSkipItem: ((Int) -> Boolean)? = null
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
// 缓存实际位置映射,避免重复计算
|
||||
private val actualPositionCache = mutableMapOf<Int, Int>()
|
||||
private var totalActualItemsCache: Int? = null
|
||||
|
||||
private val paint = Paint().apply {
|
||||
color = dividerColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val position = parent.getChildAdapterPosition(view)
|
||||
|
||||
// 检查是否应该跳过该item
|
||||
if (shouldSkipItem?.invoke(position) == true) {
|
||||
// 对于跳过的item,不设置任何间距
|
||||
outRect.bottom = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 计算实际的item位置(排除跳过的item)
|
||||
val actualPosition = calculateActualPosition(position, parent)
|
||||
val totalActualItems = calculateTotalActualItems(parent)
|
||||
|
||||
// 检查当前item后面是否还有非跳过的item
|
||||
val hasNextNonSkippedItem = hasNextNonSkippedItem(position, parent)
|
||||
|
||||
// 如果后面还有非跳过的item,则添加底部间距
|
||||
if (hasNextNonSkippedItem) {
|
||||
outRect.bottom = dividerHeight
|
||||
} else {
|
||||
outRect.bottom = 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(
|
||||
c: Canvas,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
// 当数据变化时清除缓存
|
||||
actualPositionCache.clear()
|
||||
totalActualItemsCache = null
|
||||
|
||||
val layoutManager = parent.layoutManager
|
||||
if (layoutManager == null) return
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = parent.getChildAt(i)
|
||||
val position = parent.getChildAdapterPosition(child)
|
||||
|
||||
// 检查是否应该跳过该item
|
||||
if (shouldSkipItem?.invoke(position) == true) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查当前item后面是否还有非跳过的item
|
||||
val hasNextNonSkippedItem = hasNextNonSkippedItem(position, parent)
|
||||
|
||||
// 如果后面没有非跳过的item,则跳过绘制分割线
|
||||
if (!hasNextNonSkippedItem) {
|
||||
continue
|
||||
}
|
||||
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
val left = parent.paddingLeft + marginStart
|
||||
val right = parent.width - parent.paddingRight - marginEnd
|
||||
val top = child.bottom + params.bottomMargin
|
||||
val bottom = top + dividerHeight
|
||||
|
||||
c.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat(), paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDrawOver(
|
||||
c: Canvas,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
// 如果需要绘制在内容之上,可以在这里实现
|
||||
super.onDrawOver(c, parent, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算排除跳过item后的实际位置
|
||||
*/
|
||||
private fun calculateActualPosition(position: Int, parent: RecyclerView): Int {
|
||||
if (shouldSkipItem == null) {
|
||||
return position
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
actualPositionCache[position]?.let { return it }
|
||||
|
||||
var actualPosition = 0
|
||||
for (i in 0 until position) {
|
||||
if (!shouldSkipItem.invoke(i)) {
|
||||
actualPosition++
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
actualPositionCache[position] = actualPosition
|
||||
return actualPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总的实际item数量(排除跳过的item)
|
||||
*/
|
||||
private fun calculateTotalActualItems(parent: RecyclerView): Int {
|
||||
if (shouldSkipItem == null) {
|
||||
return parent.adapter?.itemCount ?: 0
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
totalActualItemsCache?.let { return it }
|
||||
|
||||
val totalItems = parent.adapter?.itemCount ?: 0
|
||||
var actualCount = 0
|
||||
for (i in 0 until totalItems) {
|
||||
if (!shouldSkipItem.invoke(i)) {
|
||||
actualCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存结果
|
||||
totalActualItemsCache = actualCount
|
||||
return actualCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定位置后面是否还有非跳过的item
|
||||
*/
|
||||
private fun hasNextNonSkippedItem(position: Int, parent: RecyclerView): Boolean {
|
||||
if (shouldSkipItem == null) {
|
||||
return position < (parent.adapter?.itemCount ?: 0) - 1
|
||||
}
|
||||
|
||||
val totalItems = parent.adapter?.itemCount ?: 0
|
||||
for (i in position + 1 until totalItems) {
|
||||
if (!shouldSkipItem.invoke(i)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package com.remax.base.view
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* 为RecyclerView添加线性分割线
|
||||
* @param dividerHeight 分割线高度,单位dp
|
||||
* @param dividerColorRes 分割线颜色资源ID
|
||||
* @param marginStart 分割线左边距,单位dp
|
||||
* @param marginEnd 分割线右边距,单位dp
|
||||
* @param shouldSkipItem 判断是否跳过该item的函数
|
||||
*/
|
||||
fun RecyclerView.addLinearDivider(
|
||||
dividerHeight: Int,
|
||||
dividerColorRes: Int,
|
||||
marginStart: Int = 0,
|
||||
marginEnd: Int = 0,
|
||||
shouldSkipItem: ((Int) -> Boolean)? = null
|
||||
) {
|
||||
val dividerColor = ContextCompat.getColor(context, dividerColorRes)
|
||||
addItemDecoration(
|
||||
LinearDividerItemDecoration(
|
||||
dividerHeight = dividerHeight,
|
||||
dividerColor = dividerColor,
|
||||
marginStart = marginStart,
|
||||
marginEnd = marginEnd,
|
||||
shouldSkipItem = shouldSkipItem
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 为RecyclerView添加网格间距
|
||||
* @param spanCount 列数
|
||||
* @param spacing 间距,单位dp
|
||||
* @param includeEdge 是否包含边缘间距
|
||||
* @param shouldSkipItem 判断是否跳过该item的函数
|
||||
*/
|
||||
fun RecyclerView.addGridSpacing(
|
||||
spanCount: Int,
|
||||
spacing: Int,
|
||||
includeEdge: Boolean = true,
|
||||
shouldSkipItem: ((Int) -> Boolean)? = null
|
||||
) {
|
||||
addItemDecoration(
|
||||
GridSpacingItemDecoration(
|
||||
spanCount = spanCount,
|
||||
spacing = spacing,
|
||||
includeEdge = includeEdge,
|
||||
shouldSkipItem = shouldSkipItem
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 为RecyclerView添加简单的分割线
|
||||
* @param dividerColorRes 分割线颜色资源ID
|
||||
* @param dividerHeight 分割线高度,单位dp
|
||||
*/
|
||||
fun RecyclerView.addSimpleDivider(
|
||||
dividerColorRes: Int,
|
||||
dividerHeight: Int = 1
|
||||
) {
|
||||
addLinearDivider(
|
||||
dividerHeight = dividerHeight,
|
||||
dividerColorRes = dividerColorRes
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package com.remax.base.view
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.widget.FrameLayout
|
||||
import com.remax.base.R
|
||||
|
||||
/**
|
||||
* 支持圆角的FrameLayout
|
||||
* 支持自定义圆角半径、背景颜色、边框等
|
||||
*/
|
||||
class RemaxRoundedFrameLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var cornerRadius: Float = 0f
|
||||
private var cornerRadiusTopLeft: Float = 0f
|
||||
private var cornerRadiusTopRight: Float = 0f
|
||||
private var cornerRadiusBottomLeft: Float = 0f
|
||||
private var cornerRadiusBottomRight: Float = 0f
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val path = Path()
|
||||
private val rect = RectF()
|
||||
|
||||
init {
|
||||
initAttributes(context, attrs)
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
|
||||
private fun initAttributes(context: Context, attrs: AttributeSet?) {
|
||||
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.RemaxRoundedFrameLayout)
|
||||
try {
|
||||
// 读取圆角半径
|
||||
cornerRadius = typedArray.getDimension(R.styleable.RemaxRoundedFrameLayout_remaxCornerRadius, 0f)
|
||||
cornerRadiusTopLeft = typedArray.getDimension(R.styleable.RemaxRoundedFrameLayout_remaxCornerRadiusTopLeft, cornerRadius)
|
||||
cornerRadiusTopRight = typedArray.getDimension(R.styleable.RemaxRoundedFrameLayout_remaxCornerRadiusTopRight, cornerRadius)
|
||||
cornerRadiusBottomLeft = typedArray.getDimension(R.styleable.RemaxRoundedFrameLayout_remaxCornerRadiusBottomLeft, cornerRadius)
|
||||
cornerRadiusBottomRight = typedArray.getDimension(R.styleable.RemaxRoundedFrameLayout_remaxCornerRadiusBottomRight, cornerRadius)
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (cornerRadius > 0 || hasCustomCorners()) {
|
||||
drawRoundedBackground(canvas)
|
||||
}
|
||||
super.onDraw(canvas)
|
||||
}
|
||||
|
||||
private fun hasCustomCorners(): Boolean {
|
||||
return cornerRadiusTopLeft > 0 || cornerRadiusTopRight > 0 ||
|
||||
cornerRadiusBottomLeft > 0 || cornerRadiusBottomRight > 0
|
||||
}
|
||||
|
||||
private fun drawRoundedBackground(canvas: Canvas) {
|
||||
val width = width.toFloat()
|
||||
val height = height.toFloat()
|
||||
|
||||
// 重置路径
|
||||
path.reset()
|
||||
|
||||
// 设置矩形区域
|
||||
rect.set(0f, 0f, width, height)
|
||||
|
||||
// 创建圆角路径
|
||||
if (hasCustomCorners()) {
|
||||
// 使用不同的圆角半径
|
||||
path.moveTo(cornerRadiusTopLeft, 0f)
|
||||
path.lineTo(width - cornerRadiusTopRight, 0f)
|
||||
path.quadTo(width, 0f, width, cornerRadiusTopRight)
|
||||
path.lineTo(width, height - cornerRadiusBottomRight)
|
||||
path.quadTo(width, height, width - cornerRadiusBottomRight, height)
|
||||
path.lineTo(cornerRadiusBottomLeft, height)
|
||||
path.quadTo(0f, height, 0f, height - cornerRadiusBottomLeft)
|
||||
path.lineTo(0f, cornerRadiusTopLeft)
|
||||
path.quadTo(0f, 0f, cornerRadiusTopLeft, 0f)
|
||||
} else {
|
||||
// 使用统一的圆角半径
|
||||
path.addRoundRect(rect, cornerRadius, cornerRadius, Path.Direction.CW)
|
||||
}
|
||||
|
||||
// 应用裁剪路径
|
||||
canvas.clipPath(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置圆角半径
|
||||
* @param radius 圆角半径,单位dp
|
||||
*/
|
||||
fun setCornerRadius(radius: Float) {
|
||||
this.cornerRadius = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
radius,
|
||||
resources.displayMetrics
|
||||
)
|
||||
cornerRadiusTopLeft = this.cornerRadius
|
||||
cornerRadiusTopRight = this.cornerRadius
|
||||
cornerRadiusBottomLeft = this.cornerRadius
|
||||
cornerRadiusBottomRight = this.cornerRadius
|
||||
invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置各个角的圆角半径
|
||||
* @param topLeft 左上角圆角半径,单位dp
|
||||
* @param topRight 右上角圆角半径,单位dp
|
||||
* @param bottomLeft 左下角圆角半径,单位dp
|
||||
* @param bottomRight 右下角圆角半径,单位dp
|
||||
*/
|
||||
fun setCornerRadius(topLeft: Float, topRight: Float, bottomLeft: Float, bottomRight: Float) {
|
||||
this.cornerRadiusTopLeft = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
topLeft,
|
||||
resources.displayMetrics
|
||||
)
|
||||
this.cornerRadiusTopRight = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
topRight,
|
||||
resources.displayMetrics
|
||||
)
|
||||
this.cornerRadiusBottomLeft = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
bottomLeft,
|
||||
resources.displayMetrics
|
||||
)
|
||||
this.cornerRadiusBottomRight = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
bottomRight,
|
||||
resources.displayMetrics
|
||||
)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 圆角FrameLayout自定义属性 -->
|
||||
<declare-styleable name="RemaxRoundedFrameLayout">
|
||||
<!-- 圆角半径,单位dp -->
|
||||
<attr name="remaxCornerRadius" format="dimension" />
|
||||
<!-- 左上角圆角半径 -->
|
||||
<attr name="remaxCornerRadiusTopLeft" format="dimension" />
|
||||
<!-- 右上角圆角半径 -->
|
||||
<attr name="remaxCornerRadiusTopRight" format="dimension" />
|
||||
<!-- 左下角圆角半径 -->
|
||||
<attr name="remaxCornerRadiusBottomLeft" format="dimension" />
|
||||
<!-- 右下角圆角半径 -->
|
||||
<attr name="remaxCornerRadiusBottomRight" format="dimension" />
|
||||
</declare-styleable>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">base</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.remax.base
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
plugins {
|
||||
alias(libs.plugins.androidLibrary)
|
||||
alias(libs.plugins.kotlinAndroid)
|
||||
}
|
||||
|
||||
|
||||
val appConfig = findProperty("app") as Map<*, *>
|
||||
val adMobConfig = findProperty("admob") as Map<*, *>
|
||||
val adMobUnitConfig = adMobConfig["adUnitIds"] as Map<*, *>
|
||||
val pangleConfig = findProperty("pangle") as? Map<*, *>
|
||||
val pangleUnitConfig = pangleConfig?.get("adUnitIds") as? Map<*, *>
|
||||
val toponConfig = findProperty("topon") as? Map<*, *>
|
||||
val toponUnitConfig = toponConfig?.get("adUnitIds") as? Map<*, *>
|
||||
|
||||
android {
|
||||
namespace = "com.remax.bill"
|
||||
compileSdk = appConfig["compileSdk"] as Int
|
||||
|
||||
defaultConfig {
|
||||
minSdk = appConfig["minSdk"] as Int
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
manifestPlaceholders["ADMOB_APPLICATION_ID"] = adMobConfig["applicationId"] as String
|
||||
buildConfigField("String", "ADMOB_SPLASH_ID", "\"${adMobUnitConfig["splash"]}\"")
|
||||
buildConfigField("String", "ADMOB_BANNER_ID", "\"${adMobUnitConfig["banner"]}\"")
|
||||
buildConfigField("String", "ADMOB_INTERSTITIAL_ID", "\"${adMobUnitConfig["interstitial"]}\"")
|
||||
buildConfigField("String", "ADMOB_NATIVE_ID", "\"${adMobUnitConfig["native"]}\"")
|
||||
buildConfigField("String", "ADMOB_FULL_NATIVE_ID", "\"${adMobUnitConfig["full_native"]}\"")
|
||||
buildConfigField("String", "ADMOB_REWARDED_ID", "\"${adMobUnitConfig["rewarded"]}\"")
|
||||
|
||||
// Pangle配置
|
||||
buildConfigField("String", "PANGLE_APPLICATION_ID", "\"${pangleConfig!!["applicationId"]}\"")
|
||||
buildConfigField("String", "PANGLE_SPLASH_ID", "\"${pangleUnitConfig!!["splash"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_SPLASH_LANDSCAPE_ID", "\"${pangleUnitConfig["splash_landscape"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_BANNER_ID", "\"${pangleUnitConfig["banner"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_INTERSTITIAL_ID", "\"${pangleUnitConfig["interstitial"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_NATIVE_ID", "\"${pangleUnitConfig["native"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_FULL_NATIVE_ID", "\"${pangleUnitConfig["full_native"] ?: ""}\"")
|
||||
buildConfigField("String", "PANGLE_REWARDED_ID", "\"${pangleUnitConfig["rewarded"] ?: ""}\"")
|
||||
|
||||
// TopOn配置
|
||||
val toponAppId = (toponConfig?.get("applicationId") as? String).orEmpty()
|
||||
val toponAppKey = (toponConfig?.get("appKey") as? String).orEmpty()
|
||||
val toponInterstitialId = (toponUnitConfig?.get("interstitial") as? String).orEmpty()
|
||||
val toponRewardedId = (toponUnitConfig?.get("rewarded") as? String).orEmpty()
|
||||
val toponNativeId = (toponUnitConfig?.get("native") as? String).orEmpty()
|
||||
val toponSplashId = (toponUnitConfig?.get("splash") as? String).orEmpty()
|
||||
val toponFullNativeId = (toponUnitConfig?.get("full_native") as? String).orEmpty()
|
||||
val toponBannerId = (toponUnitConfig?.get("banner") as? String).orEmpty()
|
||||
buildConfigField("String", "TOPON_APPLICATION_ID", "\"$toponAppId\"")
|
||||
buildConfigField("String", "TOPON_APP_KEY", "\"$toponAppKey\"")
|
||||
buildConfigField("String", "TOPON_INTERSTITIAL_ID", "\"$toponInterstitialId\"")
|
||||
buildConfigField("String", "TOPON_REWARDED_ID", "\"$toponRewardedId\"")
|
||||
buildConfigField("String", "TOPON_NATIVE_ID", "\"$toponNativeId\"")
|
||||
buildConfigField("String", "TOPON_SPLASH_ID", "\"$toponSplashId\"")
|
||||
buildConfigField("String", "TOPON_FULL_NATIVE_ID", "\"$toponFullNativeId\"")
|
||||
buildConfigField("String", "TOPON_BANNER_ID", "\"$toponBannerId\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.androidx.lifecycle.runtime.ktx)
|
||||
//implementation(libs.gson)
|
||||
implementation(libs.utilcodex)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.lottie)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.junit)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
implementation(project(":base"))
|
||||
|
||||
// Admob SDK
|
||||
api(libs.play.services.ads)
|
||||
api("com.google.android.gms:play-services-ads-identifier:18.2.0")
|
||||
// Topon 三方适配器
|
||||
api("androidx.appcompat:appcompat:1.6.1")
|
||||
api("androidx.browser:browser:1.4.0")
|
||||
|
||||
// Pangle 聚合SDK
|
||||
api ("com.pangle.global:pag-sdk-m:7.5.6.2")
|
||||
// Pangle 三方适配器
|
||||
// Admob
|
||||
api ("com.pangle.global:admob-adapter:24.4.0.5")
|
||||
// Mintegral
|
||||
api ("com.pangle.global:mintegral-adapter:16.9.91.1")
|
||||
// Google Ad Manager
|
||||
api ("com.pangle.global:google-ad-manager-adapter:24.5.0.3")
|
||||
|
||||
// Topon 聚合SDK
|
||||
api("com.thinkup.sdk:core-tpn:6.5.16")
|
||||
api("com.thinkup.sdk:interstitial-tpn:6.5.16")
|
||||
api("com.thinkup.sdk:rewardedvideo-tpn:6.5.16")
|
||||
api("com.thinkup.sdk:nativead-tpn:6.5.16")
|
||||
api("com.thinkup.sdk:banner-tpn:6.5.16")
|
||||
api("com.thinkup.sdk:splash-tpn:6.5.16")
|
||||
|
||||
// Vungle
|
||||
api("com.thinkup.sdk:adapter-tpn-vungle:6.5.16")
|
||||
api("com.vungle:vungle-ads:7.5.0")
|
||||
api("com.google.android.gms:play-services-basement:18.1.0")
|
||||
api("com.google.android.gms:play-services-ads-identifier:18.0.1")
|
||||
// Bigo
|
||||
api("com.thinkup.sdk:adapter-tpn-bigo:6.5.16.1")
|
||||
api("com.bigossp:bigo-ads:5.5.1")
|
||||
// Pangle
|
||||
api("com.thinkup.sdk:adapter-tpn-pangle:6.5.16.2")
|
||||
api("com.google.android.gms:play-services-ads-identifier:18.2.0")
|
||||
// Facebook
|
||||
api("com.thinkup.sdk:adapter-tpn-facebook:6.5.16")
|
||||
api("com.facebook.android:audience-network-sdk:6.20.0")
|
||||
api("androidx.annotation:annotation:1.0.0")
|
||||
// Admob
|
||||
api("com.thinkup.sdk:adapter-tpn-admob:6.5.16")
|
||||
api("com.google.android.gms:play-services-ads:24.4.0")
|
||||
// Mintegral
|
||||
api("com.thinkup.sdk:adapter-tpn-mintegral:6.5.16.1")
|
||||
api("com.mbridge.msdk.oversea:mbridge_android_sdk:16.9.91")
|
||||
api("androidx.recyclerview:recyclerview:1.1.0")
|
||||
// Tramini
|
||||
api("com.thinkup.sdk:tramini-plugin-tpn:6.5.16")
|
||||
}
|
||||
|
|
@ -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.bill
|
||||
|
||||
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.bill.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="${ADMOB_APPLICATION_ID}" />
|
||||
|
||||
<!-- 广告模块内容提供者 -->
|
||||
<provider
|
||||
android:name=".ads.provider.AdModuleProvider"
|
||||
android:authorities="${applicationId}.admodule.provider"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.FullScreenNativeAdActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.NativeFullScreen" />
|
||||
<activity
|
||||
android:name=".ui.pangle.PangleFullScreenNativeAdActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.NativeFullScreen" />
|
||||
<activity
|
||||
android:name=".ui.topon.ToponFullScreenNativeAdActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.NativeFullScreen" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"natural": {
|
||||
"app_open": {
|
||||
"max_daily_show": 10,
|
||||
"max_daily_click": 3,
|
||||
"min_interval": 60
|
||||
},
|
||||
"interstitial": {
|
||||
"max_daily_show": 10,
|
||||
"max_daily_click": 3,
|
||||
"min_interval": 30
|
||||
},
|
||||
"native": {
|
||||
"max_daily_show": 10,
|
||||
"max_daily_click": 3,
|
||||
"min_interval": 30
|
||||
},
|
||||
"fullscreen_native_after_interstitial": 0,
|
||||
"show_interstitial_after_app_open_failure": 0,
|
||||
"show_interstitial_on_home_return": 0,
|
||||
"show_app_open_on_language_selection": 0,
|
||||
"random_interstitial_interval": 60,
|
||||
"show_guide_fullscreen_native": 0
|
||||
},
|
||||
"paid": {
|
||||
"app_open": {
|
||||
"max_daily_show": 20,
|
||||
"max_daily_click": 10,
|
||||
"min_interval": 0
|
||||
},
|
||||
"interstitial": {
|
||||
"max_daily_show": 20,
|
||||
"max_daily_click": 10,
|
||||
"min_interval": 0
|
||||
},
|
||||
"native": {
|
||||
"max_daily_show": 20,
|
||||
"max_daily_click": 10,
|
||||
"min_interval": 0
|
||||
},
|
||||
"fullscreen_native_after_interstitial": 3,
|
||||
"show_interstitial_after_app_open_failure": 1,
|
||||
"show_interstitial_on_home_return": 1,
|
||||
"show_app_open_on_language_selection": 0,
|
||||
"random_interstitial_interval": 60,
|
||||
"show_guide_fullscreen_native": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
|
||||
/**
|
||||
* AdActivity 拦截器
|
||||
* 用于拦截 Google AdMob 的 AdActivity 并设置隐藏导航栏
|
||||
*/
|
||||
class AdActivityInterceptor private constructor() {
|
||||
|
||||
companion object {
|
||||
private var INSTANCE: AdActivityInterceptor? = null
|
||||
|
||||
fun getInstance(): AdActivityInterceptor {
|
||||
return INSTANCE ?: AdActivityInterceptor().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
private var isRegistered = false
|
||||
|
||||
/**
|
||||
* 注册到 Application 中
|
||||
*/
|
||||
fun register(application: Application) {
|
||||
if (isRegistered) {
|
||||
AdLogger.d("AdActivityInterceptor 已经注册")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
application.registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
||||
isRegistered = true
|
||||
AdLogger.d("AdActivityInterceptor 注册成功")
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdActivityInterceptor 注册失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销注册
|
||||
*/
|
||||
fun unregister(application: Application) {
|
||||
if (!isRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
application.unregisterActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
||||
isRegistered = false
|
||||
AdLogger.d("AdActivityInterceptor 注销成功")
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdActivityInterceptor 注销失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 生命周期回调
|
||||
*/
|
||||
private val activityLifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
// 检查是否是AdActivity
|
||||
if (isAdActivity(activity)) {
|
||||
AdLogger.d("检测到AdActivity创建: ${activity.javaClass.simpleName}")
|
||||
activity.window.apply {
|
||||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Activity是否是AdActivity
|
||||
* 使用类名判断,避免混淆影响
|
||||
*/
|
||||
private fun isAdActivity(activity: Activity): Boolean {
|
||||
// 使用类名判断,即使混淆后也能通过特征识别
|
||||
val className = activity.javaClass.name
|
||||
|
||||
// 排除我们自己的广告Activity
|
||||
if (className.contains("FullScreenNativeAdActivity") ||
|
||||
className.contains("com.remax.pr.ui.FullScreenNativeAdActivity")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否是 Google AdMob 的 AdActivity 或包含广告相关特征
|
||||
return className.contains("com.google.android.gms.ads") ||
|
||||
className.contains("AdActivity") ||
|
||||
className.contains("ads") ||
|
||||
className.contains("ad")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.gms.ads.MobileAds
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* AdMob SDK 管理器
|
||||
* 负责SDK初始化和全局配置
|
||||
*/
|
||||
object AdMobManager {
|
||||
|
||||
private const val TAG = "AdMobManager"
|
||||
|
||||
private val _initializationState = MutableStateFlow<AdResult<Unit>>(AdResult.Loading)
|
||||
val initializationState: StateFlow<AdResult<Unit>> = _initializationState.asStateFlow()
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
/**
|
||||
* 初始化 AdMob SDK
|
||||
*/
|
||||
suspend fun initialize(context: Context): AdResult<Unit> {
|
||||
if (isInitialized) {
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
_initializationState.value = AdResult.Loading
|
||||
|
||||
MobileAds.initialize(context) { initializationStatus ->
|
||||
try {
|
||||
val statusMap = initializationStatus.adapterStatusMap
|
||||
AdLogger.d("AdMob SDK初始化完成")
|
||||
|
||||
// 输出各个适配器的状态
|
||||
for ((className, status) in statusMap) {
|
||||
AdLogger.d("AdMob 适配器: $className, 状态: ${status.initializationState}, 描述: ${status.description}")
|
||||
}
|
||||
|
||||
isInitialized = true
|
||||
val result = AdResult.Success(Unit)
|
||||
_initializationState.value = result
|
||||
continuation.resume(result)
|
||||
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdMob SDK初始化过程中发生异常", e)
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "SDK初始化异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
_initializationState.value = result
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查SDK是否已初始化
|
||||
*/
|
||||
fun isInitialized(): Boolean {
|
||||
return isInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前初始化状态
|
||||
*/
|
||||
fun getCurrentInitializationState(): AdResult<Unit> {
|
||||
return _initializationState.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有广告控制器的快捷访问器
|
||||
*/
|
||||
object Controllers {
|
||||
val interstitial: InterstitialAdController
|
||||
get() = InterstitialAdController.getInstance()
|
||||
|
||||
val appOpen: AppOpenAdController
|
||||
get() = AppOpenAdController.getInstance()
|
||||
|
||||
val native: NativeAdController
|
||||
get() = NativeAdController.getInstance()
|
||||
|
||||
val fullScreenNative: FullScreenNativeAdController
|
||||
get() = FullScreenNativeAdController.getInstance()
|
||||
|
||||
val banner: BannerAdController
|
||||
get() = BannerAdController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有控制器资源
|
||||
*/
|
||||
fun destroyAll() {
|
||||
// Controllers.interstitial.destroy()
|
||||
Controllers.appOpen.destroy()
|
||||
Controllers.native.destroy()
|
||||
Controllers.fullScreenNative.destroy()
|
||||
Controllers.banner.destroy()
|
||||
AdLogger.d("所有广告控制器已清理")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
/**
|
||||
* 广告操作结果
|
||||
*/
|
||||
sealed class AdResult<out T> {
|
||||
/**
|
||||
* 成功
|
||||
*/
|
||||
data class Success<T>(val data: T) : AdResult<T>()
|
||||
|
||||
/**
|
||||
* 失败
|
||||
*/
|
||||
data class Failure(val error: AdException) : AdResult<Nothing>()
|
||||
|
||||
/**
|
||||
* 加载中
|
||||
*/
|
||||
object Loading : AdResult<Nothing>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告异常信息
|
||||
*/
|
||||
data class AdException(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val cause: Throwable? = null
|
||||
){
|
||||
companion object {
|
||||
const val ERROR_NETWORK = 1001
|
||||
const val ERROR_NO_FILL = 1002
|
||||
const val ERROR_INVALID_REQUEST = 1003
|
||||
const val ERROR_INTERNAL = 1004
|
||||
const val ERROR_TIMEOUT = 1005
|
||||
const val ERROR_AD_EXPIRED = 1006
|
||||
const val ERROR_AD_ALREADY_SHOWING = 1007
|
||||
const val ERROR_NOT_LOADED = 1008
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,509 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.gms.ads.AdError
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.FullScreenContentCallback
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.google.android.gms.ads.appopen.AppOpenAd
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
/**
|
||||
* 开屏广告控制器
|
||||
* 专门处理开屏广告的加载和显示,包含广告过期逻辑
|
||||
*/
|
||||
class AppOpenAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("app_open_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("app_open_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("app_open_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("app_open_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("app_open_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("app_open_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("app_open_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AppOpenAdController"
|
||||
private const val AD_TIMEOUT = 4 * 60 * 60 * 1000L // 4小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 2
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: AppOpenAdController? = null
|
||||
|
||||
fun getInstance(): AppOpenAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: AppOpenAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedAppOpenAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的开屏广告数据类
|
||||
*/
|
||||
data class CachedAppOpenAd(
|
||||
val ad: AppOpenAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载开屏广告
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_SPLASH_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): AppOpenAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("开屏广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val adRequest = AdRequest.Builder()
|
||||
.setHttpTimeoutMillis(7000) // HTTP请求超时7秒
|
||||
.build()
|
||||
|
||||
val loadCallback = object : AppOpenAd.AppOpenAdLoadCallback() {
|
||||
override fun onAdLoaded(ad: AppOpenAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("开屏广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (ad.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
continuation.resume(ad)
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("开屏广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", adUnitId, loadTime, loadAdError.message)
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动广告加载
|
||||
AppOpenAd.load(context, adUnitId, adRequest, loadCallback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val appOpenAd = loadAd(context, adUnitId)
|
||||
if (appOpenAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedAppOpenAd(appOpenAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("开屏广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("开屏loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示开屏广告(自动处理加载和过期检查)
|
||||
* @param activity Activity上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun showAd(activity: Activity, adUnitId: String? = null,onLoaded:((isSuc: Boolean)->Unit)?=null): AdResult<Unit> {
|
||||
// 累积触发广告展示次数统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("开屏广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to (adUnitId ?: ""),
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getAppOpenConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("开屏广告累积展示失败次数: $totalShowFailCount")
|
||||
onLoaded?.invoke(false)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to (adUnitId ?: ""),
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message,
|
||||
)
|
||||
)
|
||||
return interceptResult
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_SPLASH_ID
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载开屏广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(activity, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的开屏广告,广告位ID: %s", finalAdUnitId)
|
||||
onLoaded?.invoke(true)
|
||||
|
||||
// 3. 显示广告
|
||||
val result = showAdInternal(activity, cachedAd.ad, finalAdUnitId)
|
||||
|
||||
result
|
||||
} else {
|
||||
onLoaded?.invoke(false)
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示开屏广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedAppOpenAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅查看缓存(不移除)以获取指定广告位的一个广告
|
||||
*/
|
||||
fun getCachedAdPeek(adUnitId: String): CachedAppOpenAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) adCachePool[index] else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
private suspend fun showAdInternal(activity: Activity, appOpenAd: AppOpenAd, adUnitId: String): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// 临时变量保存收益数据
|
||||
var currentAdValue: AdValue? = null
|
||||
|
||||
// 设置收益监听器
|
||||
appOpenAd.onPaidEventListener = OnPaidEventListener { adValue ->
|
||||
AdLogger.d("开屏广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 保存到临时变量
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (appOpenAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(appOpenAd, adValue)
|
||||
}
|
||||
|
||||
appOpenAd.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
totalCloseCount ++
|
||||
AdLogger.d("开屏广告关闭")
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (appOpenAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
val result = AdResult.Success(Unit)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdFailedToShowFullScreenContent(adError: AdError) {
|
||||
AdLogger.w("开屏广告显示失败: %s", adError.message)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to adError.message
|
||||
)
|
||||
)
|
||||
val result = AdResult.Failure(createAdException("显示失败: ${adError.message}"))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdShowedFullScreenContent() {
|
||||
AdLogger.d("开屏广告开始显示")
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("开屏广告累积展示次数: $totalShowCount")
|
||||
|
||||
AdConfigManager.getAppOpenConfig().recordShow()
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("开屏广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("开屏广告累积点击次数: $totalClickCount")
|
||||
AdLogger.d("开屏广告点击时收益数据: ${if (currentAdValue != null) "value=${currentAdValue!!.valueMicros}, currency=${currentAdValue!!.currencyCode}" else "暂无收益数据"}")
|
||||
|
||||
AdConfigManager.getAppOpenConfig().recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (appOpenAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("开屏广告展示完成")
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(adUnitId)) {
|
||||
AdLogger.d("开屏开始异步预加载下一个广告,广告位ID: %s", adUnitId)
|
||||
PreloadController.preload(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appOpenAd.show(activity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param appOpenAd 开屏广告对象
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(appOpenAd: AppOpenAd, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = appOpenAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = appOpenAd.adUnitId,
|
||||
adRevenuePlacement = appOpenAd.responseInfo.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "Splash"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("开屏广告真实收益数据已上报,广告位ID: ${appOpenAd.adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("开屏广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("开屏广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "Splash"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,595 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdSize
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlin.math.ceil
|
||||
import com.remax.bill.ui.BannerAdView
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Banner广告控制器
|
||||
* 提供标准Banner广告显示功能
|
||||
*/
|
||||
class BannerAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("banner_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("banner_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("banner_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("banner_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("banner_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("banner_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("banner_ad_total_shows", 0)
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdValue: AdValue? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BannerAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: BannerAdController? = null
|
||||
|
||||
fun getInstance(): BannerAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: BannerAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedBannerAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的Banner广告数据类
|
||||
*/
|
||||
private data class CachedBannerAd(
|
||||
val adView: AdView,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
private var bannerAdView: AdView? = null
|
||||
private var loadTime: Long = 0L
|
||||
private val adUnitId = BuildConfig.ADMOB_BANNER_ID
|
||||
private val bannerView = BannerAdView()
|
||||
|
||||
// 状态流
|
||||
private val _loadingState = MutableStateFlow<AdResult<AdView>>(AdResult.Loading)
|
||||
val loadingState: StateFlow<AdResult<AdView>> = _loadingState.asStateFlow()
|
||||
|
||||
private val _adExpiredState = MutableStateFlow(false)
|
||||
val adExpiredState: StateFlow<Boolean> = _adExpiredState.asStateFlow()
|
||||
|
||||
/**
|
||||
* 创建Banner广告视图
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
fun createBannerAdView(context: Context, adUnitId: String? = null): AdView {
|
||||
return AdView(context).apply {
|
||||
this.adUnitId = adUnitId ?: BuildConfig.ADMOB_BANNER_ID
|
||||
setAdSize(AdSize.BANNER) // 320x50 标准Banner尺寸
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedBannerAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位的缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "Banner"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载Banner广告
|
||||
* @param context 上下文
|
||||
* @param adView AdView实例
|
||||
*/
|
||||
private suspend fun loadAdInternal(context: Context, adView: AdView): AdView? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("AdmobBanner广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adView.adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// 设置收益监听器
|
||||
adView.onPaidEventListener = OnPaidEventListener { adValue ->
|
||||
AdLogger.d("AdmobBanner广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 存储当前广告的收益信息
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adView.adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adView.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(adView, adValue)
|
||||
}
|
||||
|
||||
adView.adListener = object : AdListener() {
|
||||
private var loadStartTime = System.currentTimeMillis()
|
||||
|
||||
override fun onAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - loadStartTime
|
||||
AdLogger.d("AdmobBanner广告加载成功,广告位ID: %s, 耗时: %dms", adView.adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adView.adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (adView.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
// 重置开始时间,为下次刷新做准备
|
||||
loadStartTime = System.currentTimeMillis()
|
||||
continuation.resume(adView)
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - loadStartTime
|
||||
AdLogger.e("AdmobBanner广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", adView.adUnitId, loadTime, loadAdError.message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adView.adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
|
||||
// 重置开始时间,为下次刷新做准备
|
||||
loadStartTime = System.currentTimeMillis()
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("AdmobBanner广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("AdmobBanner广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getBannerConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adView.adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adView.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("AdmobBanner广告展示完成")
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("AdmobBanner广告累积展示次数: $totalShowCount")
|
||||
}
|
||||
|
||||
override fun onAdOpened() {
|
||||
AdLogger.d("AdmobBanner广告打开")
|
||||
}
|
||||
|
||||
override fun onAdClosed() {
|
||||
AdLogger.d("AdmobBanner广告关闭")
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adView.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
adView.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("Admob广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
val adView = createBannerAdView(context, adUnitId)
|
||||
val loadedAdView = loadAdInternal(context, adView)
|
||||
if (loadedAdView != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedBannerAd(loadedAdView, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("AdmobBanner广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdmobBanner loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载Banner广告(可选,用于提前准备)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_BANNER_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示Banner广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun showAd(context: Context, container: ViewGroup, adUnitId: String? = null): AdResult<AdView> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_BANNER_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("AdmobBanner广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getBannerConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("AdmobBanner广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.w("AdmobBanner广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("Admob缓存为空,立即加载Banner广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(context, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
AdLogger.d("Admob使用缓存中的Banner广告,广告位ID: %s", finalAdUnitId)
|
||||
|
||||
// 显示加载视图
|
||||
container.removeAllViews()
|
||||
// container.addView(bannerView.createBannerLoadingView(context))
|
||||
|
||||
val success = bannerView.bindBannerAdToContainer(
|
||||
context, container, cachedAd.adView, null
|
||||
)
|
||||
|
||||
if (success) {
|
||||
AdConfigManager.getBannerConfig().recordShow()
|
||||
if (!isCacheFull(finalAdUnitId)) {
|
||||
PreloadController.preload(context)
|
||||
}
|
||||
AdResult.Success(cachedAd.adView)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告绑定失败"))
|
||||
}
|
||||
} else {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("AdmobBanner广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "No fill"
|
||||
)
|
||||
)
|
||||
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdLogger.e("Admob显示Banner广告失败", e)
|
||||
container.removeAllViews()
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -1,
|
||||
message = "显示Banner广告异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前广告视图
|
||||
*/
|
||||
fun getCurrentAdView(): AdView? {
|
||||
return if (!isAdExpired()) bannerAdView else null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(): Boolean {
|
||||
return bannerAdView != null && !isAdExpired()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查广告是否已过期
|
||||
*/
|
||||
fun isAdExpired(): Boolean {
|
||||
val expired = loadTime != 0L && System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
if (expired && !_adExpiredState.value) {
|
||||
_adExpiredState.value = true
|
||||
AdLogger.d("Banner广告已过期")
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取剩余有效时间(毫秒)
|
||||
*/
|
||||
fun getRemainingTime(): Long {
|
||||
if (loadTime == 0L) return 0L
|
||||
val remaining = AD_TIMEOUT - (System.currentTimeMillis() - loadTime)
|
||||
return if (remaining > 0) remaining else 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停广告
|
||||
*/
|
||||
fun pauseAd() {
|
||||
bannerAdView?.pause()
|
||||
AdLogger.d("Banner广告已暂停")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param adView Banner广告视图
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(adView: AdView, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = adView.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = adView.adUnitId,
|
||||
adRevenuePlacement = adView.responseInfo?.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "Banner"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("Banner广告真实收益数据已上报,广告位ID: ${adView.adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复广告
|
||||
*/
|
||||
fun resumeAd() {
|
||||
bannerAdView?.resume()
|
||||
AdLogger.d("Banner广告已恢复")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { cachedAd -> cachedAd.adView.destroy() }
|
||||
adCachePool.clear()
|
||||
}
|
||||
bannerAdView = null
|
||||
bannerView.reset()
|
||||
loadTime = 0L
|
||||
AdLogger.d("Banner广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
_loadingState.value = AdResult.Loading
|
||||
_adExpiredState.value = false
|
||||
AdLogger.d("Banner广告控制器已清理")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,639 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.blankj.utilcode.util.ActivityUtils
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdLoader
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.google.android.gms.ads.nativead.NativeAd
|
||||
import com.google.android.gms.ads.nativead.NativeAdOptions
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlin.math.ceil
|
||||
import com.remax.bill.ui.FullScreenNativeAdView
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* 全屏原生广告控制器
|
||||
* 专门处理全屏展示的原生广告,通常用于应用启动、页面切换等场景
|
||||
*/
|
||||
class FullScreenNativeAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("fullscreen_native_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("fullscreen_native_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("fullscreen_native_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("fullscreen_native_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("fullscreen_native_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("fullscreen_native_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("fullscreen_native_ad_total_shows", 0)
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdValue: AdValue? = null
|
||||
|
||||
// 全屏原生广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FullScreenNativeAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: FullScreenNativeAdController? = null
|
||||
|
||||
fun getInstance(): FullScreenNativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: FullScreenNativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedFullScreenNativeAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private var fullScreenNativeAd: NativeAd? = null
|
||||
private var loadTime: Long = 0L
|
||||
private val fullScreenAdView = FullScreenNativeAdView()
|
||||
|
||||
/**
|
||||
* 缓存的全屏原生广告数据类
|
||||
*/
|
||||
private data class CachedFullScreenNativeAd(
|
||||
val ad: NativeAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
// 状态流
|
||||
private val _loadingState = MutableStateFlow<AdResult<NativeAd>>(AdResult.Loading)
|
||||
val loadingState: StateFlow<AdResult<NativeAd>> = _loadingState.asStateFlow()
|
||||
|
||||
private val _showingState = MutableStateFlow<AdResult<Unit>?>(null)
|
||||
val showingState: StateFlow<AdResult<Unit>?> = _showingState.asStateFlow()
|
||||
|
||||
private val _adExpiredState = MutableStateFlow(false)
|
||||
val adExpiredState: StateFlow<Boolean> = _adExpiredState.asStateFlow()
|
||||
|
||||
var nativeAds :NativeAd ?=null
|
||||
|
||||
/**
|
||||
* 预加载全屏原生广告(可选,用于提前准备)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_FULL_NATIVE_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
fun closeEvent(adUnitId: String = ""){
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to runCatching { ActivityUtils.getTopActivity()::class.java.simpleName }.getOrDefault(""),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (nativeAds?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun getAd(context: Context, adUnitId: String? = null): AdResult<NativeAd> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_FULL_NATIVE_ID
|
||||
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("Admob缓存为空,立即加载全屏原生广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(context, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
return if (cachedAd != null) {
|
||||
AdLogger.d("Admob使用缓存中的全屏原生广告,广告位ID: %s", finalAdUnitId)
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示全屏原生广告到指定容器(简化版接口)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param lifecycleOwner 生命周期所有者
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @return AdResult<Unit> 广告显示结果
|
||||
*/
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
adUnitId: String? = null
|
||||
): AdResult<Unit> {
|
||||
totalShowTriggerCount++
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId.orEmpty(),
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getFullscreenNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
AdLogger.w("Admob全屏原生广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob全屏原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId.orEmpty(),
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 显示加载视图
|
||||
fullScreenAdView.createFullScreenLoadingView(context, container)
|
||||
|
||||
when (val result = getAd(context, adUnitId)) {
|
||||
is AdResult.Success -> {
|
||||
_showingState.value = AdResult.Loading
|
||||
|
||||
// 绑定广告到容器
|
||||
val success = fullScreenAdView.bindFullScreenNativeAdToContainer(
|
||||
context, container, result.data, lifecycleOwner
|
||||
)
|
||||
|
||||
if (success) {
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
val error = AdException(code = -1, message = "广告绑定失败")
|
||||
_showingState.value = AdResult.Failure(error)
|
||||
AdResult.Failure(error)
|
||||
}
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
AdLogger.e("Admob全屏原生广告加载失败: %s", result.error.message)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId.orEmpty(),
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
AdResult.Failure(result.error)
|
||||
}
|
||||
AdResult.Loading -> {
|
||||
AdLogger.w("Admob全屏原生广告正在加载中")
|
||||
AdResult.Loading
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob显示全屏原生广告失败", e)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId.orEmpty(),
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdResult.Failure(AdException(code = -2, message = "显示全屏原生广告异常: ${e.message}", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedFullScreenNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位的缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存池是否存在元素
|
||||
* @param adUnitId 广告位ID,如果为空则检查所有广告位
|
||||
* @return 如果缓存池中存在有效广告则返回true,否则返回false
|
||||
*/
|
||||
fun hasCachedAd(adUnitId: String? = null): Boolean {
|
||||
synchronized(adCachePool) {
|
||||
return if (adUnitId != null) {
|
||||
// 检查指定广告位是否有有效缓存
|
||||
adCachePool.any { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
} else {
|
||||
// 检查缓存池中是否有任何有效广告
|
||||
adCachePool.any { !it.isExpired() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("Admob广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
val nativeAd = loadAd(context, adUnitId)
|
||||
if (nativeAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedFullScreenNativeAd(nativeAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("Admob全屏原生广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob全屏原生loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "FullNative"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): NativeAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Admob全屏原生广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val adLoader = AdLoader.Builder(context, adUnitId)
|
||||
.forNativeAd { nativeAd ->
|
||||
nativeAds = nativeAd
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Admob全屏原生广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 设置收益监听器
|
||||
nativeAd.setOnPaidEventListener(OnPaidEventListener { adValue ->
|
||||
AdLogger.d("Admob全屏原生广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 存储当前广告的收益信息
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (nativeAd?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(adUnitId,nativeAd, adValue)
|
||||
})
|
||||
|
||||
continuation.resume(nativeAd)
|
||||
}
|
||||
.withAdListener(object : AdListener() {
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Admob全屏原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", adUnitId, loadTime, loadAdError.message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Admob全屏原生广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Admob全屏原生广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getFullscreenNativeConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (nativeAds?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("Admob全屏原生广告展示完成")
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Admob全屏原生广告累积展示次数: $totalShowCount")
|
||||
|
||||
AdConfigManager.getFullscreenNativeConfig().recordShow()
|
||||
if (!isCacheFull(adUnitId)) {
|
||||
PreloadController.preload(context)
|
||||
}
|
||||
AdLogger.d("全屏原生广告显示成功")
|
||||
}
|
||||
|
||||
override fun onAdClosed() {
|
||||
super.onAdClosed()
|
||||
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (nativeAds?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to ((currentAdValue?.valueMicros ?: 0) / 1_000_000.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.withNativeAdOptions(
|
||||
NativeAdOptions.Builder()
|
||||
.setAdChoicesPlacement(NativeAdOptions.ADCHOICES_TOP_RIGHT)
|
||||
.setMediaAspectRatio(NativeAdOptions.NATIVE_MEDIA_ASPECT_RATIO_LANDSCAPE)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
adLoader.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的广告数据
|
||||
*/
|
||||
fun getCurrentAd(): NativeAd? {
|
||||
return if (!isAdExpired()) fullScreenNativeAd else null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(): Boolean {
|
||||
return fullScreenNativeAd != null && !isAdExpired()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查广告是否已过期
|
||||
*/
|
||||
fun isAdExpired(): Boolean {
|
||||
val expired = loadTime != 0L && System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
if (expired && !_adExpiredState.value) {
|
||||
_adExpiredState.value = true
|
||||
AdLogger.d("全屏原生广告已过期")
|
||||
}
|
||||
return expired
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余有效时间(毫秒)
|
||||
*/
|
||||
fun getRemainingTime(): Long {
|
||||
if (loadTime == 0L) return 0L
|
||||
val remaining = AD_TIMEOUT - (System.currentTimeMillis() - loadTime)
|
||||
return if (remaining > 0) remaining else 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载状态
|
||||
*/
|
||||
fun getCurrentLoadingState(): AdResult<NativeAd> {
|
||||
return _loadingState.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { cachedAd -> cachedAd.ad.destroy() }
|
||||
adCachePool.clear()
|
||||
}
|
||||
fullScreenNativeAd = null
|
||||
loadTime = 0L
|
||||
AdLogger.d("全屏原生广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param nativeAd 全屏原生广告对象
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(adUnitId: String,nativeAd: NativeAd, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = adUnitId,
|
||||
adRevenuePlacement = nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "FullNative"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("全屏原生广告真实收益数据已上报,广告位ID: ${adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
_loadingState.value = AdResult.Loading
|
||||
_showingState.value = null
|
||||
_adExpiredState.value = false
|
||||
AdLogger.d("全屏原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告是否正在显示的状态
|
||||
* @return true 如果全屏原生广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.google.android.gms.ads.AdError
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.FullScreenContentCallback
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.google.android.gms.ads.interstitial.InterstitialAd
|
||||
import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.ext.AdShowExt
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlin.math.ceil
|
||||
import com.remax.bill.ui.FullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* 插页广告控制器
|
||||
*/
|
||||
class InterstitialAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("interstitial_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("interstitial_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("interstitial_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("interstitial_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("interstitial_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("interstitial_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("interstitial_ad_total_shows", 0)
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdValue: AdValue? = null
|
||||
|
||||
// 插页广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: InterstitialAdController? = null
|
||||
|
||||
fun getInstance(): InterstitialAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: InterstitialAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedInterstitialAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的插页广告数据类
|
||||
*/
|
||||
data class CachedInterstitialAd(
|
||||
val ad: InterstitialAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > 1 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_INTERSTITIAL_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告
|
||||
*/
|
||||
suspend fun showAd(activity: Activity, adUnitId: String? = null,ignoreFullNative: Boolean = false): AdResult<Unit> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_INTERSTITIAL_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Admob插页广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getInterstitialConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob插页广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
return interceptResult
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
// 是否加载全屏原生
|
||||
val interval = AdConfigManager.getFullscreenNativeAfterInterstitialCount()
|
||||
val todayShowInter = AdConfigManager.getInterstitialConfig().getDailyShowCount()
|
||||
val needShowNativeFull = interval > 0 && todayShowInter > 0 && todayShowInter % interval == 0
|
||||
AdLogger.d("Admob当日已展示${todayShowInter}个插页,每显示${interval}个插页将显示原生,下一个是否显示全屏原生${needShowNativeFull}")
|
||||
|
||||
if(!ignoreFullNative && needShowNativeFull){
|
||||
return AdShowExt.showFullScreenNativeAdInContainer(activity,true)
|
||||
}
|
||||
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
// 插页阻塞loading
|
||||
ADLoadingDialog.show(activity)
|
||||
AdLogger.d("Admob缓存为空,立即加载插页广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(activity, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
ADLoadingDialog.hide()
|
||||
AdLogger.d("Admob使用缓存中的插页广告,广告位ID: %s", finalAdUnitId)
|
||||
|
||||
// 3. 显示广告
|
||||
val result = showAdInternal(activity, cachedAd.ad, finalAdUnitId)
|
||||
|
||||
result
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob显示插页广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
} finally {
|
||||
ADLoadingDialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): InterstitialAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Admob插页广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val adRequest = AdRequest.Builder()
|
||||
.setHttpTimeoutMillis(7000) // 7秒超时
|
||||
.build()
|
||||
|
||||
InterstitialAd.load(context, adUnitId, adRequest, object : InterstitialAdLoadCallback() {
|
||||
override fun onAdLoaded(interstitialAd: InterstitialAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Admob插页广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (interstitialAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 设置收益监听器
|
||||
interstitialAd.onPaidEventListener = OnPaidEventListener { adValue ->
|
||||
AdLogger.d("Admob插页广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 存储当前广告的收益信息
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (interstitialAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(interstitialAd, adValue)
|
||||
}
|
||||
|
||||
continuation.resume(interstitialAd)
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Admob插页广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", adUnitId, loadTime, loadAdError.message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("Admob广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val interstitialAd = loadAd(context, adUnitId)
|
||||
if (interstitialAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedInterstitialAd(interstitialAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("Admob插页广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob插页loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedInterstitialAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅查看缓存(不移除)以获取指定广告位的一个广告。
|
||||
*/
|
||||
fun getCachedAdPeek(adUnitId: String): CachedInterstitialAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) adCachePool[index] else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
private suspend fun showAdInternal(activity: Activity, interstitialAd: InterstitialAd, adUnitId: String): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
interstitialAd.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
AdLogger.d("Admob插页广告关闭")
|
||||
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (interstitialAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Success(Unit)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdFailedToShowFullScreenContent(adError: AdError) {
|
||||
AdLogger.w("Admob插页广告显示失败: %s", adError.message)
|
||||
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob插页广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"ad_source" to (interstitialAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"reason" to adError.message
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Failure(createAdException("显示失败: ${adError.message}"))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdShowedFullScreenContent() {
|
||||
AdLogger.d("Admob插页广告开始显示")
|
||||
|
||||
AdConfigManager.getInterstitialConfig().recordShow()
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Admob插页广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Admob插页广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getInterstitialConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to activity::class.java.simpleName,
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (interstitialAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("Admob插页广告展示完成")
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Admob插页广告累积展示次数: $totalShowCount")
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(adUnitId)) {
|
||||
PreloadController.preload(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interstitialAd.show(activity)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("插页广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param interstitialAd 插页广告对象
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(interstitialAd: InterstitialAd, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = interstitialAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = interstitialAd.adUnitId,
|
||||
adRevenuePlacement = interstitialAd.responseInfo.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "Interstitial"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("插页广告真实收益数据已上报,广告位ID: ${interstitialAd.adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("插页广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "Interstitial"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插页广告是否正在显示的状态
|
||||
* @return true 如果插页广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.gms.ads.AdListener
|
||||
import com.google.android.gms.ads.AdLoader
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.google.android.gms.ads.nativead.NativeAd
|
||||
import com.google.android.gms.ads.nativead.NativeAdOptions
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlin.math.ceil
|
||||
import com.remax.bill.ui.NativeAdView
|
||||
import com.remax.bill.ui.NativeAdStyle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 原生广告控制器
|
||||
* 提供原生广告的加载和管理功能
|
||||
*/
|
||||
class NativeAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("native_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("native_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("native_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("native_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("native_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("native_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("native_ad_total_shows", 0)
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdValue: AdValue? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NativeAdController? = null
|
||||
|
||||
fun getInstance(): NativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedNativeAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val nativeAdView = NativeAdView()
|
||||
|
||||
// 状态流
|
||||
private val _loadingState = MutableStateFlow<AdResult<NativeAd>>(AdResult.Loading)
|
||||
val loadingState: StateFlow<AdResult<NativeAd>> = _loadingState.asStateFlow()
|
||||
|
||||
/**
|
||||
* 缓存的原生广告数据类
|
||||
*/
|
||||
private data class CachedNativeAd(
|
||||
val ad: NativeAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载原生广告(可选,用于提前准备)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_NATIVE_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原生广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun getAd(context: Context, adUnitId: String? = null): AdResult<NativeAd> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_NATIVE_ID
|
||||
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("Admob缓存为空,立即加载原生广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(context, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
return if (cachedAd != null) {
|
||||
AdLogger.d("Admob使用缓存中的原生广告,广告位ID: %s", finalAdUnitId)
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示原生广告到指定容器(简化版接口)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param style 广告样式,默认为标准样式
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @return 是否显示成功
|
||||
*/
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
style: NativeAdStyle = NativeAdStyle.STANDARD,
|
||||
adUnitId: String? = null
|
||||
): Boolean {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_NATIVE_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Admob原生广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.w("Admob原生广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
return false
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 显示加载视图
|
||||
// container.removeAllViews()
|
||||
// container.addView(nativeAdView.createLoadingView(context))
|
||||
|
||||
when (val result = getAd(context, adUnitId)) {
|
||||
is AdResult.Success -> {
|
||||
// 绑定广告到容器
|
||||
nativeAdView.bindNativeAdToContainer(context, container, result.data, style)
|
||||
true
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
|
||||
// 显示错误视图
|
||||
// container.removeAllViews()
|
||||
// container.addView(nativeAdView.createErrorView(context, result.error.message))
|
||||
false
|
||||
}
|
||||
AdResult.Loading -> {
|
||||
// 保持加载状态
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "${e.message}"
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.e("Admob显示原生广告失败", e)
|
||||
// container.removeAllViews()
|
||||
// container.addView(nativeAdView.createErrorView(context, "广告显示异常"))
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): NativeAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Admob原生广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
_loadingState.value = AdResult.Loading
|
||||
val startTime = System.currentTimeMillis()
|
||||
var nativeAds :NativeAd ?=null
|
||||
|
||||
val adLoader = AdLoader.Builder(context, adUnitId)
|
||||
.forNativeAd { nativeAd ->
|
||||
nativeAds = nativeAd
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Admob原生广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 设置收益监听器
|
||||
nativeAd.setOnPaidEventListener(OnPaidEventListener { adValue ->
|
||||
AdLogger.d("Admob原生广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 存储当前广告的收益信息
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(adUnitId,nativeAd, adValue)
|
||||
})
|
||||
|
||||
val result = AdResult.Success(nativeAd)
|
||||
_loadingState.value = result
|
||||
continuation.resume(nativeAd)
|
||||
}
|
||||
.withAdListener(object : AdListener() {
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Admob原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", adUnitId, loadTime, loadAdError.message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = loadAdError.code,
|
||||
message = loadAdError.message
|
||||
)
|
||||
)
|
||||
_loadingState.value = result
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Admob原生广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Admob原生广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getNativeConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (nativeAds?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("Admob原生广告展示完成")
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Admob原生广告累积展示次数: $totalShowCount")
|
||||
|
||||
// 记录展示
|
||||
AdConfigManager.getNativeConfig().recordShow()
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(adUnitId ?: BuildConfig.ADMOB_NATIVE_ID)) {
|
||||
PreloadController.preload(context)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClosed() {
|
||||
super.onAdClosed()
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to context::class.java.simpleName,
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (nativeAds?.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 } ?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.withNativeAdOptions(
|
||||
NativeAdOptions.Builder()
|
||||
.setAdChoicesPlacement(NativeAdOptions.ADCHOICES_TOP_RIGHT)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
adLoader.loadAd(AdRequest.Builder().build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("Admob广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val nativeAd = loadAd(context, adUnitId)
|
||||
if (nativeAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedNativeAd(nativeAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("Admob原生广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob原生loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的广告数据
|
||||
*/
|
||||
fun getCurrentAd(): NativeAd? {
|
||||
return getCachedAd(BuildConfig.ADMOB_NATIVE_ID)?.ad
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(): Boolean {
|
||||
return getCachedAdCount(BuildConfig.ADMOB_NATIVE_ID) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载状态
|
||||
*/
|
||||
fun getCurrentLoadingState(): AdResult<NativeAd> {
|
||||
return _loadingState.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { cachedAd ->
|
||||
cachedAd.ad.destroy()
|
||||
}
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("原生广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param nativeAd 原生广告对象
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(adUnitId: String,nativeAd: NativeAd, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = adUnitId,
|
||||
adRevenuePlacement = nativeAd.responseInfo?.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "Native"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("原生广告真实收益数据已上报,广告位ID: ${adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
_loadingState.value = AdResult.Loading
|
||||
AdLogger.d("原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "Native"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleAppOpenAdController
|
||||
import com.remax.bill.ads.pangle.PangleBannerAdController
|
||||
import com.remax.bill.ads.pangle.PangleFullScreenNativeAdController
|
||||
import com.remax.bill.ads.pangle.PangleInterstitialAdController
|
||||
import com.remax.bill.ads.pangle.PangleNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnBannerAdController
|
||||
import com.remax.bill.ads.topon.TopOnFullScreenNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnInterstitialAdController
|
||||
import com.remax.bill.ads.topon.TopOnNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnSplashAdController
|
||||
import com.remax.bill.ads.bidding.BiddingPlatformController
|
||||
import com.remax.bill.ads.bidding.BiddingPlatformController.AdType
|
||||
import com.remax.bill.ads.bidding.BiddingPlatformController.Platform
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object PreloadController {
|
||||
fun preload(context: Context){
|
||||
MainScope().launch {
|
||||
try {
|
||||
AdLogger.d("插页开始异步预加载下一个广告,广告位ID: %s", BuildConfig.ADMOB_INTERSTITIAL_ID)
|
||||
InterstitialAdController.getInstance().preloadAd(context, BuildConfig.ADMOB_INTERSTITIAL_ID)
|
||||
}catch (e: Exception){
|
||||
AdLogger.e("插页异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
MainScope().launch {
|
||||
try {
|
||||
AdLogger.d("banner开始异步预加载下一个广告,广告位ID: %s", BuildConfig.ADMOB_BANNER_ID)
|
||||
BannerAdController.getInstance().preloadAd(context,BuildConfig.ADMOB_BANNER_ID)
|
||||
}catch (e: Exception){
|
||||
AdLogger.e("banner异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("原生开始异步预加载下一个广告,广告位ID: %s", BuildConfig.ADMOB_NATIVE_ID)
|
||||
NativeAdController.getInstance().preloadAd(context, BuildConfig.ADMOB_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("全屏原生开始异步预加载下一个广告,广告位ID: %s", BuildConfig.ADMOB_FULL_NATIVE_ID)
|
||||
FullScreenNativeAdController.getInstance().preloadAd(context,BuildConfig.ADMOB_FULL_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("全屏原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载 Pangle 所有广告类型(根据 BiddingPlatformController 配置)
|
||||
*/
|
||||
fun preloadPangle(context: Context) {
|
||||
// 开屏广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.APP_OPEN, Platform.PANGLE)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle开屏开始异步预加载,广告位ID: %s", BuildConfig.PANGLE_SPLASH_ID)
|
||||
PangleAppOpenAdController.getInstance().preloadAd(context, BuildConfig.PANGLE_SPLASH_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle开屏异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插页广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.INTERSTITIAL, Platform.PANGLE)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle插页开始异步预加载,广告位ID: %s", BuildConfig.PANGLE_INTERSTITIAL_ID)
|
||||
PangleInterstitialAdController.getInstance().preloadAd(context, BuildConfig.PANGLE_INTERSTITIAL_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle插页异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Banner广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.BANNER, Platform.PANGLE)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle Banner开始异步预加载,广告位ID: %s", BuildConfig.PANGLE_BANNER_ID)
|
||||
PangleBannerAdController.getInstance().preloadAd(context, BuildConfig.PANGLE_BANNER_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle Banner异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 原生广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.NATIVE, Platform.PANGLE)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle原生开始异步预加载,广告位ID: %s", BuildConfig.PANGLE_NATIVE_ID)
|
||||
PangleNativeAdController.getInstance().preloadAd(context, BuildConfig.PANGLE_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏原生广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.FULL_SCREEN_NATIVE, Platform.PANGLE)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle全屏原生开始异步预加载,广告位ID: %s", BuildConfig.PANGLE_FULL_NATIVE_ID)
|
||||
PangleFullScreenNativeAdController.getInstance().preloadAd(context, BuildConfig.PANGLE_FULL_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle全屏原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载 TopOn 所有广告类型(根据 BiddingPlatformController 配置)
|
||||
*/
|
||||
fun preloadTopOn(context: Activity) {
|
||||
// 开屏广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.APP_OPEN, Platform.TOPON)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn开屏开始异步预加载,广告位ID: %s", BuildConfig.TOPON_SPLASH_ID)
|
||||
TopOnSplashAdController.getInstance().preloadAd(context, BuildConfig.TOPON_SPLASH_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn开屏异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插页广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.INTERSTITIAL, Platform.TOPON)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn插页开始异步预加载,广告位ID: %s", BuildConfig.TOPON_INTERSTITIAL_ID)
|
||||
TopOnInterstitialAdController.getInstance().preloadAd(context, BuildConfig.TOPON_INTERSTITIAL_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn插页异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Banner广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.BANNER, Platform.TOPON)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn Banner开始异步预加载,广告位ID: %s", BuildConfig.TOPON_BANNER_ID)
|
||||
TopOnBannerAdController.getInstance().preloadAd(context, BuildConfig.TOPON_BANNER_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn Banner异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 原生广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.NATIVE, Platform.TOPON)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn原生开始异步预加载,广告位ID: %s", BuildConfig.TOPON_NATIVE_ID)
|
||||
TopOnNativeAdController.getInstance().preloadAd(context, BuildConfig.TOPON_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏原生广告
|
||||
if (BiddingPlatformController.isPlatformEnabled(AdType.FULL_SCREEN_NATIVE, Platform.TOPON)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn全屏原生开始异步预加载,广告位ID: %s", BuildConfig.TOPON_FULL_NATIVE_ID)
|
||||
TopOnFullScreenNativeAdController.getInstance().preloadAd(context, BuildConfig.TOPON_FULL_NATIVE_ID)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn全屏原生异步预加载广告失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
package com.remax.bill.ads
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.google.android.gms.ads.AdError
|
||||
import com.google.android.gms.ads.AdRequest
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.ads.FullScreenContentCallback
|
||||
import com.google.android.gms.ads.LoadAdError
|
||||
import com.google.android.gms.ads.OnPaidEventListener
|
||||
import com.google.android.gms.ads.OnUserEarnedRewardListener
|
||||
import com.google.android.gms.ads.rewarded.RewardItem
|
||||
import com.google.android.gms.ads.rewarded.RewardedAd
|
||||
import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.also
|
||||
import kotlin.collections.count
|
||||
import kotlin.collections.firstOrNull
|
||||
import kotlin.collections.indexOfFirst
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.let
|
||||
import kotlin.text.orEmpty
|
||||
import kotlin.to
|
||||
|
||||
/**
|
||||
* 激励广告控制器
|
||||
*/
|
||||
class RewardedAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("rewarded_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("rewarded_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("rewarded_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("rewarded_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("rewarded_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("rewarded_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("rewarded_ad_total_shows", 0)
|
||||
|
||||
// 累积奖励获得次数统计(持久化)
|
||||
private var totalRewardEarnedCount by KvIntDelegate("rewarded_ad_total_reward_earned", 0)
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdValue: AdValue? = null
|
||||
|
||||
// 激励广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: RewardedAdController? = null
|
||||
|
||||
fun getInstance(): RewardedAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: RewardedAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedRewardedAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的激励广告数据类
|
||||
*/
|
||||
private data class CachedRewardedAd(
|
||||
val ad: RewardedAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > 1 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_REWARDED_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告
|
||||
*/
|
||||
suspend fun showAd(activity: Activity, adUnitId: String? = null, onRewardEarned: ((RewardItem) -> Unit)? = null): AdResult<Unit> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.ADMOB_REWARDED_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Admob激励广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
// when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getRewardedConfig())) {
|
||||
// is AdResult.Failure -> {
|
||||
// // 累积展示失败次数统计
|
||||
// totalShowFailCount++
|
||||
// AdLogger.d("激励广告累积展示失败次数: $totalShowFailCount")
|
||||
//
|
||||
// reportAdData(
|
||||
// eventName = "ad_show_fail",
|
||||
// params = mapOf(
|
||||
// "ad_unit_name" to finalAdUnitId,
|
||||
// "position" to PositionGet.get(),
|
||||
// "number" to totalShowFailCount,
|
||||
// "reason" to interceptResult.error.message
|
||||
// )
|
||||
// )
|
||||
//
|
||||
// return interceptResult
|
||||
// }
|
||||
// else -> { /* continue */ }
|
||||
// }
|
||||
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
// 激励广告阻塞loading
|
||||
ADLoadingDialog.show(activity)
|
||||
AdLogger.d("Admob缓存为空,立即加载激励广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(activity, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
ADLoadingDialog.hide()
|
||||
AdLogger.d("Admob使用缓存中的激励广告,广告位ID: %s", finalAdUnitId)
|
||||
|
||||
// 3. 显示广告
|
||||
val result = showAdInternal(activity, cachedAd.ad, finalAdUnitId, onRewardEarned)
|
||||
|
||||
result
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob显示激励广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
} finally {
|
||||
ADLoadingDialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): RewardedAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Admob激励广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val adRequest = AdRequest.Builder()
|
||||
.setHttpTimeoutMillis(7000) // 7秒超时
|
||||
.build()
|
||||
|
||||
RewardedAd.load(context, adUnitId, adRequest, object : RewardedAdLoadCallback() {
|
||||
override fun onAdLoaded(rewardedAd: RewardedAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Admob激励广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 设置收益监听器
|
||||
rewardedAd.onPaidEventListener = OnPaidEventListener { adValue ->
|
||||
AdLogger.d("Admob激励广告收益回调: value=${adValue.valueMicros}, currency=${adValue.currencyCode}")
|
||||
|
||||
// 存储当前广告的收益信息
|
||||
currentAdValue = adValue
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 }
|
||||
?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
reportAdRevenueWithValue(rewardedAd, adValue)
|
||||
|
||||
val revenueUsd = (adValue.valueMicros / 1_000_000.0).toLong()
|
||||
}
|
||||
|
||||
continuation.resume(rewardedAd)
|
||||
}
|
||||
|
||||
override fun onAdFailedToLoad(loadAdError: LoadAdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e(
|
||||
"Admob激励广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s",
|
||||
adUnitId,
|
||||
loadTime,
|
||||
loadAdError.message
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (loadAdError.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to loadAdError.message
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("Admob广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val rewardedAd = loadAd(context, adUnitId)
|
||||
if (rewardedAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedRewardedAd(rewardedAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d(
|
||||
"Admob激励广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d",
|
||||
adUnitId,
|
||||
currentCount,
|
||||
maxCacheSizePerAdUnit
|
||||
)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Admob激励loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedRewardedAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekCachedAd(adUnitId: String = BuildConfig.ADMOB_REWARDED_ID): RewardedAd? {
|
||||
return synchronized(adCachePool) {
|
||||
adCachePool.firstOrNull { it.adUnitId == adUnitId && !it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
private suspend fun showAdInternal(
|
||||
activity: Activity,
|
||||
rewardedAd: RewardedAd,
|
||||
adUnitId: String,
|
||||
onRewardEarned: ((RewardItem) -> Unit)?
|
||||
): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
var hasRewarded = false
|
||||
rewardedAd.fullScreenContentCallback = object : FullScreenContentCallback() {
|
||||
override fun onAdDismissedFullScreenContent() {
|
||||
AdLogger.d("Admob激励广告关闭")
|
||||
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 }
|
||||
?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: ""),
|
||||
"isended" to if (hasRewarded) "true" else ""
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Success(Unit)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdFailedToShowFullScreenContent(adError: AdError) {
|
||||
AdLogger.w("Admob激励广告显示失败: %s", adError.message)
|
||||
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Admob激励广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"reason" to adError.message
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Failure(createAdException("显示失败: ${adError.message}"))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdShowedFullScreenContent() {
|
||||
AdLogger.d("Admob激励广告开始显示")
|
||||
|
||||
AdConfigManager.getRewardedConfig()?.recordShow()
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Admob激励广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Admob激励广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getRewardedConfig()?.recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty()),
|
||||
"value" to (currentAdValue?.let { it.valueMicros / 1_000_000.0 }
|
||||
?: 0.0),
|
||||
"currency" to (currentAdValue?.currencyCode ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdImpression() {
|
||||
AdLogger.d("Admob激励广告展示完成")
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Admob激励广告累积展示次数: $totalShowCount")
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(adUnitId)) {
|
||||
PreloadController.preload(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置奖励获得监听器
|
||||
val onUserEarnedRewardListener = OnUserEarnedRewardListener { rewardItem ->
|
||||
AdLogger.d("用户获得奖励: type=${rewardItem.type}, amount=${rewardItem.amount}")
|
||||
|
||||
// 累积奖励获得次数统计
|
||||
totalRewardEarnedCount++
|
||||
AdLogger.d("激励广告累积奖励获得次数: $totalRewardEarnedCount")
|
||||
hasRewarded = true
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_reward_earned",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalRewardEarnedCount,
|
||||
"reward_type" to rewardItem.type,
|
||||
"reward_amount" to rewardItem.amount,
|
||||
"ad_source" to (rewardedAd.responseInfo?.loadedAdapterResponseInfo?.adSourceName.orEmpty())
|
||||
)
|
||||
)
|
||||
|
||||
// 调用外部回调
|
||||
onRewardEarned?.invoke(rewardItem)
|
||||
}
|
||||
|
||||
rewardedAd.show(activity, onUserEarnedRewardListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("激励广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param rewardedAd 激励广告对象
|
||||
* @param adValue 广告收益值
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(rewardedAd: RewardedAd, adValue: AdValue) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adValue.valueMicros / 1_000_000.0,
|
||||
currencyCode = adValue.currencyCode
|
||||
),
|
||||
adRevenueNetwork = rewardedAd.responseInfo.loadedAdapterResponseInfo?.adSourceName.orEmpty(),
|
||||
adRevenueUnit = rewardedAd.adUnitId,
|
||||
adRevenuePlacement = rewardedAd.responseInfo.loadedAdapterResponseInfo?.adSourceInstanceName.orEmpty(),
|
||||
adFormat = "Rewarded"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("激励广告真实收益数据已上报,广告位ID: ${rewardedAd.adUnitId}, 收益: ${adValue.valueMicros}微元 ${adValue.currencyCode}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("激励广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Admob",
|
||||
"ad_format" to "Rewarded"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激励广告是否正在显示的状态
|
||||
* @return true 如果激励广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.base.ext.KvStringDelegate
|
||||
|
||||
/**
|
||||
* 广告聚合源控制器
|
||||
* 用于管理当前聚合源选择(同时应用于插页和激励广告)
|
||||
* 支持持久化存储,默认为空表示使用竞价逻辑
|
||||
*/
|
||||
object AdSourceController {
|
||||
|
||||
/**
|
||||
* 聚合源类型
|
||||
*/
|
||||
enum class AdSource {
|
||||
BIDDING, // 竞价(默认)
|
||||
ADMOB, // AdMob
|
||||
PANGLE, // Pangle
|
||||
TOPON // TopOn
|
||||
}
|
||||
|
||||
// 当前聚合源(持久化)
|
||||
private var currentSource by KvStringDelegate("ad_source_current", null)
|
||||
|
||||
/**
|
||||
* 获取当前聚合源
|
||||
* @return AdSource,如果为空则返回 BIDDING(竞价)
|
||||
*/
|
||||
fun getCurrentSource(): AdSource {
|
||||
return currentSource?.let {
|
||||
try {
|
||||
AdSource.valueOf(it)
|
||||
} catch (e: Exception) {
|
||||
AdSource.BIDDING
|
||||
}
|
||||
} ?: AdSource.BIDDING
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前聚合源
|
||||
* @param source AdSource
|
||||
*/
|
||||
fun setCurrentSource(source: AdSource) {
|
||||
currentSource = source.name
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合源显示名称
|
||||
*/
|
||||
fun getSourceDisplayName(source: AdSource): String {
|
||||
return when (source) {
|
||||
AdSource.BIDDING -> "竞价"
|
||||
AdSource.ADMOB -> "Admob"
|
||||
AdSource.PANGLE -> "Pangle"
|
||||
AdSource.TOPON -> "Topon"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的聚合源
|
||||
*/
|
||||
fun getAllSources(): List<AdSource> {
|
||||
return listOf(
|
||||
AdSource.BIDDING,
|
||||
AdSource.ADMOB,
|
||||
AdSource.PANGLE,
|
||||
AdSource.TOPON
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示聚合源选择弹窗
|
||||
* @param context 上下文
|
||||
* @param onSourceChanged 聚合源改变后的回调,用于刷新UI
|
||||
*/
|
||||
fun showAdSourceSelection(context: Context, onSourceChanged: () -> Unit = {}) {
|
||||
val currentSource = getCurrentSource()
|
||||
AdSourceSelectionBottomSheet.show(context, currentSource) { selectedSource ->
|
||||
setCurrentSource(selectedSource)
|
||||
onSourceChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.remax.bill.R
|
||||
|
||||
/**
|
||||
* 广告聚合源选择底部弹窗
|
||||
*/
|
||||
class AdSourceSelectionBottomSheet(
|
||||
private val context: Context,
|
||||
private val currentSource: AdSourceController.AdSource,
|
||||
private val onSourceSelected: (AdSourceController.AdSource) -> Unit
|
||||
) : BottomSheetDialog(context) {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var adapter: SourceAdapter
|
||||
|
||||
init {
|
||||
setupDialog()
|
||||
}
|
||||
|
||||
private fun setupDialog() {
|
||||
val layout = LayoutInflater.from(context).inflate(R.layout.dialog_ad_source_selection, null)
|
||||
setContentView(layout)
|
||||
|
||||
window?.setBackgroundDrawableResource(android.R.color.transparent)
|
||||
|
||||
initViews(layout)
|
||||
setupRecyclerView()
|
||||
}
|
||||
|
||||
private fun initViews(layout: View) {
|
||||
recyclerView = layout.findViewById(R.id.rv_sources)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
val sources = AdSourceController.getAllSources()
|
||||
adapter = SourceAdapter(sources, currentSource) { source ->
|
||||
onSourceSelected(source)
|
||||
dismiss()
|
||||
}
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
/**
|
||||
* 源选择适配器
|
||||
*/
|
||||
private class SourceAdapter(
|
||||
private val sources: List<AdSourceController.AdSource>,
|
||||
private val currentSource: AdSourceController.AdSource,
|
||||
private val onItemClick: (AdSourceController.AdSource) -> Unit
|
||||
) : RecyclerView.Adapter<SourceAdapter.ViewHolder>() {
|
||||
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val tvName: TextView = itemView.findViewById(R.id.tv_source_name)
|
||||
val tvCheck: TextView = itemView.findViewById(R.id.tv_check)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_ad_source, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val source = sources[position]
|
||||
val displayName = AdSourceController.getSourceDisplayName(source)
|
||||
holder.tvName.text = displayName
|
||||
|
||||
// 显示选中状态
|
||||
val isSelected = source == currentSource
|
||||
holder.tvCheck.visibility = if (isSelected) View.VISIBLE else View.GONE
|
||||
|
||||
holder.itemView.setOnClickListener {
|
||||
onItemClick(source)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = sources.size
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 显示聚合源选择弹窗
|
||||
*/
|
||||
fun show(
|
||||
context: Context,
|
||||
currentSource: AdSourceController.AdSource,
|
||||
onSourceSelected: (AdSourceController.AdSource) -> Unit
|
||||
) {
|
||||
val dialog = AdSourceSelectionBottomSheet(context, currentSource, onSourceSelected)
|
||||
dialog.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.AdMobManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleManager
|
||||
import com.remax.bill.ads.topon.TopOnManager
|
||||
import kotlin.getOrElse
|
||||
import kotlin.runCatching
|
||||
|
||||
/**
|
||||
* App Open 竞价初始化控制器
|
||||
* 负责初始化参与竞价的3个网络(AdMob 与 Pangle 与Topon)
|
||||
*/
|
||||
object AppOpenBiddingInitializer {
|
||||
|
||||
private const val TAG = "AppOpenBiddingInit"
|
||||
|
||||
suspend fun initialize(context: Context,icon:Int): AdResult<Unit> {
|
||||
AdLogger.d("$TAG 开始初始化")
|
||||
|
||||
val admobResult = runCatching {
|
||||
AdMobManager.initialize(context)
|
||||
}.getOrElse { throwable ->
|
||||
AdLogger.e("$TAG AdMob 初始化异常", throwable)
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "AdMob 初始化异常: ${throwable.message}",
|
||||
cause = throwable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (admobResult is AdResult.Failure) {
|
||||
AdLogger.d("$TAG AdMob 初始化失败: ${admobResult.error.message}")
|
||||
return admobResult
|
||||
}
|
||||
|
||||
val pangleResult = runCatching {
|
||||
PangleManager.initialize(
|
||||
context = context,
|
||||
appId = BuildConfig.PANGLE_APPLICATION_ID,
|
||||
appIconId = icon
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
AdLogger.e("$TAG Pangle 初始化异常", throwable)
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "Pangle 初始化异常: ${throwable.message}",
|
||||
cause = throwable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (pangleResult is AdResult.Failure) {
|
||||
AdLogger.d("$TAG Pangle 初始化失败: ${pangleResult.error.message}")
|
||||
return pangleResult
|
||||
}
|
||||
|
||||
val toponResult = runCatching {
|
||||
TopOnManager.initialize(
|
||||
context = context,
|
||||
appId = BuildConfig.TOPON_APPLICATION_ID,
|
||||
appKey = BuildConfig.TOPON_APP_KEY
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
AdLogger.e("$TAG TopOn 初始化异常", throwable)
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "TopOn 初始化异常: ${throwable.message}",
|
||||
cause = throwable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (toponResult is AdResult.Failure) {
|
||||
AdLogger.d("$TAG TopOn 初始化失败: ${toponResult.error.message}")
|
||||
return toponResult
|
||||
}
|
||||
|
||||
AdLogger.d("$TAG 初始化完成")
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.app.Activity
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.AppOpenAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleAppOpenAdController
|
||||
import com.remax.bill.ads.topon.TopOnSplashAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 开屏广告竞价控制器
|
||||
* 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示
|
||||
*/
|
||||
object AppOpenBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_SPLASH_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_SPLASH_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_SPLASH_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val context = activity.applicationContext
|
||||
val admobController = AppOpenAdController.getInstance()
|
||||
val pangleController = PangleAppOpenAdController.getInstance()
|
||||
val toponController = TopOnSplashAdController.getInstance()
|
||||
|
||||
// 根据平台配置决定是否参与比价
|
||||
val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.APP_OPEN)
|
||||
val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.APP_OPEN)
|
||||
val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.APP_OPEN)
|
||||
|
||||
// 异步并行加载启用的广告
|
||||
val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope {
|
||||
val admobDeferred = async {
|
||||
if (admobEnabled) {
|
||||
runCatching { admobController.preloadAd(context, admobAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
if (pangleEnabled) {
|
||||
runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val toponDeferred = async {
|
||||
if (toponEnabled) {
|
||||
runCatching { toponController.preloadAd(context, toponPlacementId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await())
|
||||
}
|
||||
|
||||
// 获取 AdMob 收益
|
||||
val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) {
|
||||
admobController.getCachedAdPeek(admobAdUnitId)?.ad?.let { ad ->
|
||||
AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 Pangle 收益
|
||||
val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) {
|
||||
pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 TopOn 收益
|
||||
val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) {
|
||||
toponController.peekCachedAd(toponPlacementId)?.let { splashAd ->
|
||||
runCatching { splashAd.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"开屏竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s",
|
||||
admobValueUsd, if (admobEnabled) "" else "(禁用)",
|
||||
pangleValueUsd, if (pangleEnabled) "" else "(禁用)",
|
||||
toponValueUsd, if (toponEnabled) "" else "(禁用)"
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog))
|
||||
|
||||
// 只在启用的平台中选择胜出者
|
||||
val winner = when {
|
||||
admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE
|
||||
toponEnabled -> BiddingWinner.TOPON
|
||||
admobEnabled -> BiddingWinner.ADMOB
|
||||
pangleEnabled -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.ADMOB // 默认
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.app.Activity
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.BannerAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleBannerAdController
|
||||
import com.remax.bill.ads.topon.TopOnBannerAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Banner广告竞价控制器
|
||||
* 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示
|
||||
*/
|
||||
object BannerBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_BANNER_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_BANNER_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_BANNER_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val context = activity.applicationContext
|
||||
val admobController = BannerAdController.getInstance()
|
||||
val pangleController = PangleBannerAdController.getInstance()
|
||||
val toponController = TopOnBannerAdController.getInstance()
|
||||
|
||||
// 根据平台配置决定是否参与比价
|
||||
val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.BANNER)
|
||||
val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.BANNER)
|
||||
val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.BANNER)
|
||||
|
||||
// 异步并行加载启用的广告
|
||||
val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope {
|
||||
val admobDeferred = async {
|
||||
if (admobEnabled) {
|
||||
runCatching { admobController.preloadAd(context, admobAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
if (pangleEnabled) {
|
||||
runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val toponDeferred = async {
|
||||
if (toponEnabled) {
|
||||
runCatching { toponController.preloadAd(activity, toponPlacementId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await())
|
||||
}
|
||||
|
||||
// 获取 AdMob 收益
|
||||
val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) {
|
||||
admobController.getCurrentAdView()?.let { ad ->
|
||||
AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 Pangle 收益
|
||||
val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) {
|
||||
pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 TopOn 收益
|
||||
val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) {
|
||||
toponController.peekCachedAd(toponPlacementId)?.let { ad ->
|
||||
runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"Banner竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s",
|
||||
admobValueUsd, if (admobEnabled) "" else "(禁用)",
|
||||
pangleValueUsd, if (pangleEnabled) "" else "(禁用)",
|
||||
toponValueUsd, if (toponEnabled) "" else "(禁用)"
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog))
|
||||
|
||||
// 只在启用的平台中选择胜出者
|
||||
val winner = when {
|
||||
admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE
|
||||
toponEnabled -> BiddingWinner.TOPON
|
||||
admobEnabled -> BiddingWinner.ADMOB
|
||||
pangleEnabled -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.ADMOB // 默认
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
/**
|
||||
* 比价平台控制器
|
||||
* 用于控制各广告类型参与比价的平台
|
||||
*
|
||||
* 当前策略:
|
||||
* - 插页广告:AdMob、Pangle、TopOn 都参与比价
|
||||
* - Banner广告:AdMob、TopOn 参与比价
|
||||
* - 开屏广告:AdMob、TopOn 参与比价
|
||||
* - 原生广告:AdMob、TopOn 参与比价
|
||||
* - 全屏原生广告:AdMob、TopOn 参与比价
|
||||
*/
|
||||
object BiddingPlatformController {
|
||||
|
||||
/**
|
||||
* 广告类型
|
||||
*/
|
||||
enum class AdType {
|
||||
INTERSTITIAL, // 插页广告
|
||||
BANNER, // Banner广告
|
||||
APP_OPEN, // 开屏广告
|
||||
NATIVE, // 原生广告
|
||||
FULL_SCREEN_NATIVE // 全屏原生广告
|
||||
}
|
||||
|
||||
/**
|
||||
* 广告平台
|
||||
*/
|
||||
enum class Platform {
|
||||
ADMOB,
|
||||
PANGLE,
|
||||
TOPON
|
||||
}
|
||||
|
||||
// ==================== 平台配置(写死) ====================
|
||||
|
||||
// 插页广告:AdMob + Pangle + TopOn
|
||||
private const val INTERSTITIAL_ADMOB = true
|
||||
private const val INTERSTITIAL_PANGLE = true
|
||||
private const val INTERSTITIAL_TOPON = true
|
||||
|
||||
// Banner广告:AdMob + TopOn
|
||||
private const val BANNER_ADMOB = true
|
||||
private const val BANNER_PANGLE = false
|
||||
private const val BANNER_TOPON = true
|
||||
|
||||
// 开屏广告:AdMob + TopOn
|
||||
private const val APP_OPEN_ADMOB = true
|
||||
private const val APP_OPEN_PANGLE = false
|
||||
private const val APP_OPEN_TOPON = true
|
||||
|
||||
// 原生广告:AdMob + TopOn
|
||||
private const val NATIVE_ADMOB = true
|
||||
private const val NATIVE_PANGLE = false
|
||||
private const val NATIVE_TOPON = true
|
||||
|
||||
// 全屏原生广告:AdMob + TopOn
|
||||
private const val FULL_SCREEN_NATIVE_ADMOB = true
|
||||
private const val FULL_SCREEN_NATIVE_PANGLE = false
|
||||
private const val FULL_SCREEN_NATIVE_TOPON = true
|
||||
|
||||
/**
|
||||
* 检查指定广告类型的平台是否启用
|
||||
*/
|
||||
fun isPlatformEnabled(adType: AdType, platform: Platform): Boolean {
|
||||
return when (adType) {
|
||||
AdType.INTERSTITIAL -> when (platform) {
|
||||
Platform.ADMOB -> INTERSTITIAL_ADMOB
|
||||
Platform.PANGLE -> INTERSTITIAL_PANGLE
|
||||
Platform.TOPON -> INTERSTITIAL_TOPON
|
||||
}
|
||||
AdType.BANNER -> when (platform) {
|
||||
Platform.ADMOB -> BANNER_ADMOB
|
||||
Platform.PANGLE -> BANNER_PANGLE
|
||||
Platform.TOPON -> BANNER_TOPON
|
||||
}
|
||||
AdType.APP_OPEN -> when (platform) {
|
||||
Platform.ADMOB -> APP_OPEN_ADMOB
|
||||
Platform.PANGLE -> APP_OPEN_PANGLE
|
||||
Platform.TOPON -> APP_OPEN_TOPON
|
||||
}
|
||||
AdType.NATIVE -> when (platform) {
|
||||
Platform.ADMOB -> NATIVE_ADMOB
|
||||
Platform.PANGLE -> NATIVE_PANGLE
|
||||
Platform.TOPON -> NATIVE_TOPON
|
||||
}
|
||||
AdType.FULL_SCREEN_NATIVE -> when (platform) {
|
||||
Platform.ADMOB -> FULL_SCREEN_NATIVE_ADMOB
|
||||
Platform.PANGLE -> FULL_SCREEN_NATIVE_PANGLE
|
||||
Platform.TOPON -> FULL_SCREEN_NATIVE_TOPON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告类型启用的平台列表
|
||||
*/
|
||||
fun getEnabledPlatforms(adType: AdType): List<Platform> {
|
||||
return Platform.values().filter { isPlatformEnabled(adType, it) }
|
||||
}
|
||||
|
||||
// ==================== 便捷方法 ====================
|
||||
|
||||
fun isAdmobEnabled(adType: AdType): Boolean = isPlatformEnabled(adType, Platform.ADMOB)
|
||||
fun isPangleEnabled(adType: AdType): Boolean = isPlatformEnabled(adType, Platform.PANGLE)
|
||||
fun isToponEnabled(adType: AdType): Boolean = isPlatformEnabled(adType, Platform.TOPON)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
enum class BiddingWinner {
|
||||
ADMOB, PANGLE, TOPON
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.FullScreenNativeAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleFullScreenNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnFullScreenNativeAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 全屏原生广告竞价控制器
|
||||
* 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示
|
||||
*/
|
||||
object FullScreenNativeBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
context: Context,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_FULL_NATIVE_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_FULL_NATIVE_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_FULL_NATIVE_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
context: Context,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val applicationContext = context.applicationContext
|
||||
val admobController = FullScreenNativeAdController.getInstance()
|
||||
val pangleController = PangleFullScreenNativeAdController.getInstance()
|
||||
val toponController = TopOnFullScreenNativeAdController.getInstance()
|
||||
|
||||
// 根据平台配置决定是否参与比价
|
||||
val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE)
|
||||
val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE)
|
||||
val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE)
|
||||
|
||||
// 异步并行加载启用的广告
|
||||
val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope {
|
||||
val admobDeferred = async {
|
||||
if (admobEnabled) {
|
||||
runCatching { admobController.preloadAd(applicationContext, admobAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
if (pangleEnabled) {
|
||||
runCatching { pangleController.preloadAd(applicationContext, pangleAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val toponDeferred = async {
|
||||
if (toponEnabled) {
|
||||
runCatching { toponController.preloadAd(applicationContext, toponPlacementId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await())
|
||||
}
|
||||
|
||||
// 获取 AdMob 收益
|
||||
val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) {
|
||||
admobController.getCurrentAd()?.let { ad ->
|
||||
AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 Pangle 收益
|
||||
val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) {
|
||||
pangleController.getCurrentAd(pangleAdUnitId)?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 TopOn 收益
|
||||
val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) {
|
||||
toponController.peekCachedAd(toponPlacementId)?.let { ad ->
|
||||
runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"全屏原生竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s",
|
||||
admobValueUsd, if (admobEnabled) "" else "(禁用)",
|
||||
pangleValueUsd, if (pangleEnabled) "" else "(禁用)",
|
||||
toponValueUsd, if (toponEnabled) "" else "(禁用)"
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog))
|
||||
|
||||
// 只在启用的平台中选择胜出者
|
||||
val winner = when {
|
||||
admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE
|
||||
toponEnabled -> BiddingWinner.TOPON
|
||||
admobEnabled -> BiddingWinner.ADMOB
|
||||
pangleEnabled -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.ADMOB // 默认
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.app.Activity
|
||||
import com.blankj.utilcode.util.ToastUtils
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.InterstitialAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleInterstitialAdController
|
||||
import com.remax.bill.ads.topon.TopOnInterstitialAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
import kotlin.text.toDouble
|
||||
|
||||
/**
|
||||
* 插页广告竞价控制器
|
||||
* 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示
|
||||
*/
|
||||
object InterstitialBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_INTERSTITIAL_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_INTERSTITIAL_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_INTERSTITIAL_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val context = activity.applicationContext
|
||||
val admobController = InterstitialAdController.getInstance()
|
||||
val pangleController = PangleInterstitialAdController.getInstance()
|
||||
val toponController = TopOnInterstitialAdController.getInstance()
|
||||
|
||||
// 根据平台配置决定是否参与比价
|
||||
val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.INTERSTITIAL)
|
||||
val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.INTERSTITIAL)
|
||||
val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.INTERSTITIAL)
|
||||
|
||||
// 异步并行加载启用的广告
|
||||
val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope {
|
||||
val admobDeferred = async {
|
||||
if (admobEnabled) {
|
||||
runCatching { admobController.loadAdToCache(context, admobAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
if (pangleEnabled) {
|
||||
runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val toponDeferred = async {
|
||||
if (toponEnabled) {
|
||||
runCatching { toponController.preloadAd(context, toponPlacementId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await())
|
||||
}
|
||||
|
||||
// 获取 AdMob 收益
|
||||
val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) {
|
||||
admobController.getCachedAdPeek(admobAdUnitId)?.ad?.let { ad ->
|
||||
AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 Pangle 收益
|
||||
val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) {
|
||||
pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 TopOn 收益
|
||||
val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) {
|
||||
toponController.getCurrentAd(toponPlacementId)?.let { ad ->
|
||||
runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"插页竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s",
|
||||
admobValueUsd, if (admobEnabled) "" else "(禁用)",
|
||||
pangleValueUsd, if (pangleEnabled) "" else "(禁用)",
|
||||
toponValueUsd, if (toponEnabled) "" else "(禁用)"
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog))
|
||||
|
||||
// 只在启用的平台中选择胜出者
|
||||
val winner = when {
|
||||
admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE
|
||||
toponEnabled -> BiddingWinner.TOPON
|
||||
admobEnabled -> BiddingWinner.ADMOB
|
||||
pangleEnabled -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.ADMOB // 默认
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.NativeAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnNativeAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 原生广告竞价控制器
|
||||
* 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示
|
||||
*/
|
||||
object NativeBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
context: Context,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_NATIVE_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_NATIVE_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_NATIVE_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
context: Context,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val applicationContext = context.applicationContext
|
||||
val admobController = NativeAdController.getInstance()
|
||||
val pangleController = PangleNativeAdController.getInstance()
|
||||
val toponController = TopOnNativeAdController.getInstance()
|
||||
|
||||
// 根据平台配置决定是否参与比价
|
||||
val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.NATIVE)
|
||||
val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.NATIVE)
|
||||
val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.NATIVE)
|
||||
|
||||
// 异步并行加载启用的广告
|
||||
val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope {
|
||||
val admobDeferred = async {
|
||||
if (admobEnabled) {
|
||||
runCatching { admobController.preloadAd(applicationContext, admobAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
if (pangleEnabled) {
|
||||
runCatching { pangleController.preloadAd(applicationContext, pangleAdUnitId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
val toponDeferred = async {
|
||||
if (toponEnabled) {
|
||||
runCatching { toponController.preloadAd(applicationContext, toponPlacementId) }.getOrNull()
|
||||
} else null
|
||||
}
|
||||
Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await())
|
||||
}
|
||||
|
||||
// 获取 AdMob 收益
|
||||
val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) {
|
||||
admobController.getCurrentAd()?.let { ad ->
|
||||
AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 Pangle 收益
|
||||
val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) {
|
||||
pangleController.getCurrentAd(pangleAdUnitId)?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
// 获取 TopOn 收益
|
||||
val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) {
|
||||
toponController.peekCachedAd(toponPlacementId)?.let { ad ->
|
||||
runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
} else 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"原生竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s",
|
||||
admobValueUsd, if (admobEnabled) "" else "(禁用)",
|
||||
pangleValueUsd, if (pangleEnabled) "" else "(禁用)",
|
||||
toponValueUsd, if (toponEnabled) "" else "(禁用)"
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog))
|
||||
|
||||
// 只在启用的平台中选择胜出者
|
||||
val winner = when {
|
||||
admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE
|
||||
toponEnabled -> BiddingWinner.TOPON
|
||||
admobEnabled -> BiddingWinner.ADMOB
|
||||
pangleEnabled -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.ADMOB // 默认
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package com.remax.bill.ads.bidding
|
||||
|
||||
import android.app.Activity
|
||||
import com.blankj.utilcode.util.ToastUtils
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.RewardedAdController
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleRewardedAdController
|
||||
import com.remax.bill.ads.topon.TopOnRewardedAdController
|
||||
import com.remax.bill.ads.util.AdmobReflectionUtil
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.Locale
|
||||
import kotlin.text.toDouble
|
||||
|
||||
/**
|
||||
* 激励广告竞价控制器
|
||||
*/
|
||||
object RewardedBiddingManager {
|
||||
|
||||
suspend fun bidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String = BuildConfig.ADMOB_REWARDED_ID,
|
||||
pangleAdUnitId: String = BuildConfig.PANGLE_REWARDED_ID,
|
||||
toponPlacementId: String = BuildConfig.TOPON_REWARDED_ID,
|
||||
): BiddingWinner {
|
||||
// 检查是否设置了固定的聚合源
|
||||
val source = AdSourceController.getCurrentSource()
|
||||
if (source != AdSourceController.AdSource.BIDDING) {
|
||||
// 如果设置了固定源,直接返回对应的 BiddingWinner
|
||||
return when (source) {
|
||||
AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB
|
||||
AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE
|
||||
AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON
|
||||
AdSourceController.AdSource.BIDDING -> {
|
||||
// 不会执行到这里,但为了完整性保留
|
||||
performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用竞价逻辑
|
||||
return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId)
|
||||
}
|
||||
|
||||
private suspend fun performBidding(
|
||||
activity: Activity,
|
||||
admobAdUnitId: String,
|
||||
pangleAdUnitId: String,
|
||||
toponPlacementId: String,
|
||||
): BiddingWinner {
|
||||
val context = activity.applicationContext
|
||||
val admobController = RewardedAdController.getInstance()
|
||||
val pangleController = PangleRewardedAdController.getInstance()
|
||||
val toponController = TopOnRewardedAdController.getInstance()
|
||||
|
||||
// 异步并行加载3个广告
|
||||
coroutineScope {
|
||||
val admobDeferred = async {
|
||||
runCatching {
|
||||
admobController.loadAdToCache(context, admobAdUnitId)
|
||||
}.getOrNull()
|
||||
}
|
||||
val pangleDeferred = async {
|
||||
runCatching {
|
||||
pangleController.preloadAd(context, pangleAdUnitId)
|
||||
}.getOrNull()
|
||||
}
|
||||
val toponDeferred = async {
|
||||
runCatching {
|
||||
toponController.preloadAd(context, toponPlacementId)
|
||||
}.getOrNull()
|
||||
}
|
||||
// 等待所有加载完成
|
||||
admobDeferred.await()
|
||||
pangleDeferred.await()
|
||||
toponDeferred.await()
|
||||
}
|
||||
|
||||
val admobRewardedAd = admobController.peekCachedAd(admobAdUnitId)
|
||||
val admobValueUsd = admobRewardedAd?.let { rewardedAd ->
|
||||
AdmobReflectionUtil.getRevenue(rewardedAd)?.valueMicros?.toDouble()?.div(1_000_000.0)
|
||||
} ?: 0.0
|
||||
|
||||
val pangleRewardedAd = pangleController.getCurrentAd(pangleAdUnitId)
|
||||
val pangleValueUsd = pangleRewardedAd?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
|
||||
val toponRewardedAd = toponController.getCurrentAd(toponPlacementId)
|
||||
val toponValueUsd = toponRewardedAd?.let { ad ->
|
||||
runCatching {
|
||||
ad.checkValidAdCaches().firstOrNull()?.publisherRevenue
|
||||
}.getOrNull() ?: 0.0
|
||||
} ?: 0.0
|
||||
|
||||
val biddingLog = String.format(
|
||||
Locale.US,
|
||||
"激励竞价结果 -> AdMob: %.8f 美元, Pangle: %.8f 美元, TopOn: %.8f 美元",
|
||||
admobValueUsd,
|
||||
pangleValueUsd,
|
||||
toponValueUsd
|
||||
)
|
||||
AdLogger.d(biddingLog)
|
||||
// ToastUtils.showLong(biddingLog)
|
||||
DataReportManager.reportDataByName("ThinkingData","bidding", mapOf("log" to biddingLog))
|
||||
|
||||
val winner = when {
|
||||
admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB
|
||||
pangleValueUsd >= toponValueUsd -> BiddingWinner.PANGLE
|
||||
else -> BiddingWinner.TOPON
|
||||
}
|
||||
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.remax.bill.ads.config
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 广告配置管理器
|
||||
*/
|
||||
class AdConfig private constructor(
|
||||
private val context: Context,
|
||||
private val configKey: String,
|
||||
private val maxDailyShow: Int,
|
||||
private val maxDailyClick: Int,
|
||||
private val minInterval: Long
|
||||
) {
|
||||
companion object {
|
||||
// SP存储键前缀
|
||||
private const val SP_NAME_PREFIX = "ad_config_"
|
||||
private const val KEY_DAILY_SHOW_COUNT = "daily_show_count"
|
||||
private const val KEY_DAILY_CLICK_COUNT = "daily_click_count"
|
||||
private const val KEY_LAST_SHOW_TIME = "last_show_time"
|
||||
private const val KEY_LAST_DATE = "last_date"
|
||||
|
||||
// 默认配置
|
||||
private const val DEFAULT_MAX_DAILY_SHOW = 50 // 每日最大展示次数
|
||||
private const val DEFAULT_MAX_DAILY_CLICK = 10 // 每日最大点击次数
|
||||
private const val DEFAULT_MIN_INTERVAL = 30L // 最小展示间隔(秒)
|
||||
}
|
||||
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(
|
||||
SP_NAME_PREFIX + configKey,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取当日展示次数
|
||||
*/
|
||||
fun getDailyShowCount(): Int {
|
||||
checkAndResetDaily()
|
||||
return sp.getInt(KEY_DAILY_SHOW_COUNT, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当日点击次数
|
||||
*/
|
||||
fun getDailyClickCount(): Int {
|
||||
checkAndResetDaily()
|
||||
return sp.getInt(KEY_DAILY_CLICK_COUNT, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取距离上次展示的间隔(秒)
|
||||
*/
|
||||
fun getLastShowInterval(): Long {
|
||||
val lastShowTime = sp.getLong(KEY_LAST_SHOW_TIME, 0L)
|
||||
if (lastShowTime == 0L) return Long.MAX_VALUE
|
||||
return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - lastShowTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大每日展示次数
|
||||
*/
|
||||
fun getMaxDailyShow(): Int = maxDailyShow
|
||||
|
||||
/**
|
||||
* 获取最大每日点击次数
|
||||
*/
|
||||
fun getMaxDailyClick(): Int = maxDailyClick
|
||||
|
||||
/**
|
||||
* 获取最小展示间隔(秒)
|
||||
*/
|
||||
fun getMinInterval(): Long = minInterval
|
||||
|
||||
/**
|
||||
* 获取配置键
|
||||
*/
|
||||
fun getConfigKey(): String = configKey
|
||||
|
||||
/**
|
||||
* 记录展示
|
||||
*/
|
||||
fun recordShow() {
|
||||
checkAndResetDaily()
|
||||
sp.edit {
|
||||
putInt(KEY_DAILY_SHOW_COUNT, getDailyShowCount() + 1)
|
||||
putLong(KEY_LAST_SHOW_TIME, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置上次展示时间(用于处理系统时间异常)
|
||||
*/
|
||||
fun resetLastShowTime() {
|
||||
sp.edit {
|
||||
putLong(KEY_LAST_SHOW_TIME, 0L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录点击
|
||||
*/
|
||||
fun recordClick() {
|
||||
checkAndResetDaily()
|
||||
sp.edit {
|
||||
putInt(KEY_DAILY_CLICK_COUNT, getDailyClickCount() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并重置每日统计
|
||||
*/
|
||||
private fun checkAndResetDaily() {
|
||||
val today = java.time.LocalDate.now().toString()
|
||||
val lastDate = sp.getString(KEY_LAST_DATE, "")
|
||||
|
||||
if (today != lastDate) {
|
||||
// 新的一天,重置统计
|
||||
sp.edit {
|
||||
putString(KEY_LAST_DATE, today)
|
||||
putInt(KEY_DAILY_SHOW_COUNT, 0)
|
||||
putInt(KEY_DAILY_CLICK_COUNT, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建造者
|
||||
*/
|
||||
class Builder(private val context: Context, private val configKey: String) {
|
||||
private var maxDailyShow: Int = DEFAULT_MAX_DAILY_SHOW
|
||||
private var maxDailyClick: Int = DEFAULT_MAX_DAILY_CLICK
|
||||
private var minInterval: Long = DEFAULT_MIN_INTERVAL
|
||||
|
||||
/**
|
||||
* 设置每日最大展示次数
|
||||
*/
|
||||
fun setMaxDailyShow(count: Int): Builder {
|
||||
require(count > 0) { "每日最大展示次数必须大于0" }
|
||||
maxDailyShow = count
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置每日最大点击次数
|
||||
*/
|
||||
fun setMaxDailyClick(count: Int): Builder {
|
||||
require(count > 0) { "每日最大点击次数必须大于0" }
|
||||
maxDailyClick = count
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最小展示间隔(秒)
|
||||
*/
|
||||
fun setMinInterval(seconds: Long): Builder {
|
||||
require(seconds >= 0) { "最小展示间隔不能为负数" }
|
||||
minInterval = seconds
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建配置实例
|
||||
*/
|
||||
fun build(): AdConfig {
|
||||
return AdConfig(
|
||||
context = context.applicationContext,
|
||||
configKey = configKey,
|
||||
maxDailyShow = maxDailyShow,
|
||||
maxDailyClick = maxDailyClick,
|
||||
minInterval = minInterval
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.remax.bill.ads.config
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* 广告配置数据类
|
||||
*/
|
||||
data class AdConfigData(
|
||||
@SerializedName("natural")
|
||||
val natural: ChannelConfig,
|
||||
@SerializedName("paid")
|
||||
val paid: ChannelConfig
|
||||
) {
|
||||
data class ChannelConfig(
|
||||
@SerializedName("app_open")
|
||||
val appOpen: AdTypeConfig,
|
||||
@SerializedName("interstitial")
|
||||
val interstitial: AdTypeConfig,
|
||||
@SerializedName("native")
|
||||
val native: AdTypeConfig,
|
||||
@SerializedName("fullscreen_native_after_interstitial")
|
||||
val fullscreenNativeAfterInterstitial: Int,
|
||||
@SerializedName("show_interstitial_after_app_open_failure")
|
||||
val showInterstitialAfterAppOpenFailure: Int,
|
||||
@SerializedName("show_interstitial_on_home_return")
|
||||
val showInterstitialOnHomeReturn: Int,
|
||||
@SerializedName("show_app_open_on_language_selection")
|
||||
val showAppOpenOnLanguageSelection: Int,
|
||||
@SerializedName("random_interstitial_interval")
|
||||
val randomInterstitialInterval: Int,
|
||||
@SerializedName("show_guide_fullscreen_native")
|
||||
val showGuideFullscreenNative: Int
|
||||
)
|
||||
|
||||
data class AdTypeConfig(
|
||||
@SerializedName("max_daily_show")
|
||||
val maxDailyShow: Int,
|
||||
@SerializedName("max_daily_click")
|
||||
val maxDailyClick: Int,
|
||||
@SerializedName("min_interval")
|
||||
val minInterval: Int
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
package com.remax.bill.ads.config
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.remax.base.controller.UserChannelController
|
||||
import com.remax.base.ext.KvStringDelegate
|
||||
import com.remax.base.utils.RemoteConfigManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
import kotlin.text.isNotEmpty
|
||||
|
||||
/**
|
||||
* 广告配置管理器
|
||||
*/
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object AdConfigManager {
|
||||
private const val CONFIG_FILE_NAME = "ad_config.json"
|
||||
|
||||
private const val CONFIG_KEY_INTERSTITIAL = "interstitial"
|
||||
private const val CONFIG_KEY_NATIVE = "native"
|
||||
private const val CONFIG_KEY_BANNER = "banner"
|
||||
private const val CONFIG_KEY_APP_OPEN = "app_open"
|
||||
private const val CONFIG_KEY_FULLSCREEN_NATIVE = "fullscreen_native"
|
||||
|
||||
private var adConfigJsonFromRemote by KvStringDelegate("adConfigJsonRemote", "")
|
||||
|
||||
private var configData: AdConfigData? = null
|
||||
private var context: Context? = null
|
||||
|
||||
/**
|
||||
* 初始化所有配置
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
try {
|
||||
// 保存Context引用
|
||||
this.context = context
|
||||
|
||||
// 1. 先使用本地配置进行初始化
|
||||
initializeWithLocalConfig(context)
|
||||
|
||||
// 2. 监听用户渠道变化
|
||||
setupChannelListener()
|
||||
|
||||
fetchRemoteConfig()
|
||||
|
||||
AdLogger.d("广告配置初始化成功,当前渠道: ${UserChannelController.getCurrentChannel()}")
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("广告配置初始化失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步获取远程配置
|
||||
*/
|
||||
private fun fetchRemoteConfig() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("开始获取远程广告配置")
|
||||
val remoteJsonString = RemoteConfigManager.getString("adConfigJson", "")
|
||||
|
||||
if (remoteJsonString != null && remoteJsonString.isNotEmpty()) {
|
||||
AdLogger.d("成功获取远程广告配置")
|
||||
val remoteConfig = parseConfigFromAssets(remoteJsonString)
|
||||
|
||||
// 更新本地配置
|
||||
configData = remoteConfig
|
||||
adConfigJsonFromRemote = remoteJsonString
|
||||
AdLogger.d("远程广告配置更新成功")
|
||||
} else {
|
||||
AdLogger.w("远程广告配置为空或获取超时,使用本地配置")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("获取远程广告配置异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置渠道监听器
|
||||
*/
|
||||
private fun setupChannelListener() {
|
||||
UserChannelController.addChannelChangeListener(object : UserChannelController.ChannelChangeListener {
|
||||
override fun onChannelChanged(oldChannel: UserChannelController.UserChannelType, newChannel: UserChannelController.UserChannelType) {
|
||||
AdLogger.d("广告渠道变化: ${oldChannel.value} -> ${newChannel.value}")
|
||||
// 渠道变化时,可以在这里做一些额外的处理
|
||||
// 比如重新加载配置、清理缓存等
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用本地配置初始化
|
||||
*/
|
||||
private fun initializeWithLocalConfig(context: Context) {
|
||||
// 解析 JSON 配置
|
||||
val jsonString = adConfigJsonFromRemote.orEmpty().takeIf { it.isNotEmpty() }?: context.assets.open(CONFIG_FILE_NAME).bufferedReader().use { it.readText() }
|
||||
configData = parseConfigFromAssets(jsonString)
|
||||
|
||||
AdLogger.d("本地广告配置初始化完成")
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 assets 解析配置
|
||||
*/
|
||||
private fun parseConfigFromAssets(jsonString: String): AdConfigData {
|
||||
return try {
|
||||
Gson().fromJson(jsonString, AdConfigData::class.java)
|
||||
} catch (e: IOException) {
|
||||
AdLogger.e("读取配置文件失败", e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("解析配置文件失败", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插页广告配置
|
||||
*/
|
||||
fun getInterstitialConfig(): AdConfig {
|
||||
return createInterstitialConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原生广告配置
|
||||
*/
|
||||
fun getNativeConfig(): AdConfig {
|
||||
return createNativeConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Banner广告配置
|
||||
*/
|
||||
fun getBannerConfig(): AdConfig {
|
||||
return createBannerConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取开屏广告配置
|
||||
*/
|
||||
fun getAppOpenConfig(): AdConfig {
|
||||
return createAppOpenConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告配置
|
||||
*/
|
||||
fun getFullscreenNativeConfig(): AdConfig {
|
||||
return createFullscreenNativeConfig()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激励广告配置
|
||||
*/
|
||||
fun getRewardedConfig(): AdConfig? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取是否显示引导页全屏原生广告
|
||||
*/
|
||||
fun shouldShowGuideFullscreenNative(): Boolean {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.showGuideFullscreenNative == 1
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.showGuideFullscreenNative == 1
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插屏结束后的全屏Native广告数量
|
||||
*/
|
||||
fun getFullscreenNativeAfterInterstitialCount(): Int {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.fullscreenNativeAfterInterstitial
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.fullscreenNativeAfterInterstitial
|
||||
}
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取开屏失败后是否展示插屏
|
||||
*/
|
||||
fun shouldShowInterstitialAfterAppOpenFailure(): Boolean {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.showInterstitialAfterAppOpenFailure == 1
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.showInterstitialAfterAppOpenFailure == 1
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取返回主页时是否展示插屏
|
||||
*/
|
||||
fun shouldShowInterstitialOnHomeReturn(): Boolean {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.showInterstitialOnHomeReturn == 1
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.showInterstitialOnHomeReturn == 1
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语言选择时是否展示开屏广告
|
||||
*/
|
||||
fun shouldShowAppOpenOnLanguageSelection(): Boolean {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.showAppOpenOnLanguageSelection == 1
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.showAppOpenOnLanguageSelection == 1
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取随机插屏页间隔(秒)
|
||||
*/
|
||||
fun getRandomInterstitialInterval(): Int {
|
||||
return configData?.let { config ->
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural.randomInterstitialInterval
|
||||
UserChannelController.UserChannelType.PAID -> config.paid.randomInterstitialInterval
|
||||
}
|
||||
}?.takeIf { it > 0 } ?: 60
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建插页广告配置(根据当前渠道)
|
||||
*/
|
||||
private fun createInterstitialConfig(): AdConfig {
|
||||
val config = checkNotNull(configData) { "请先调用 initialize 初始化配置" }
|
||||
val ctx = checkNotNull(context) { "Context 未初始化" }
|
||||
val channelConfig = getCurrentChannelConfig(config)
|
||||
|
||||
return AdConfig.Builder(ctx, CONFIG_KEY_INTERSTITIAL)
|
||||
.setMaxDailyShow(channelConfig.interstitial.maxDailyShow)
|
||||
.setMaxDailyClick(channelConfig.interstitial.maxDailyClick)
|
||||
.setMinInterval(channelConfig.interstitial.minInterval.toLong())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建原生广告配置(根据当前渠道)
|
||||
*/
|
||||
private fun createNativeConfig(): AdConfig {
|
||||
val config = checkNotNull(configData) { "请先调用 initialize 初始化配置" }
|
||||
val ctx = checkNotNull(context) { "Context 未初始化" }
|
||||
val channelConfig = getCurrentChannelConfig(config)
|
||||
|
||||
return AdConfig.Builder(ctx, CONFIG_KEY_NATIVE)
|
||||
.setMaxDailyShow(channelConfig.native.maxDailyShow)
|
||||
.setMaxDailyClick(channelConfig.native.maxDailyClick)
|
||||
.setMinInterval(channelConfig.native.minInterval.toLong())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Banner广告配置(根据当前渠道)
|
||||
*/
|
||||
private fun createBannerConfig(): AdConfig {
|
||||
val config = checkNotNull(configData) { "请先调用 initialize 初始化配置" }
|
||||
val ctx = checkNotNull(context) { "Context 未初始化" }
|
||||
val channelConfig = getCurrentChannelConfig(config)
|
||||
|
||||
return AdConfig.Builder(ctx, CONFIG_KEY_BANNER)
|
||||
.setMaxDailyShow(channelConfig.native.maxDailyShow)
|
||||
.setMaxDailyClick(channelConfig.native.maxDailyClick)
|
||||
.setMinInterval(channelConfig.native.minInterval.toLong())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建开屏广告配置(根据当前渠道)
|
||||
*/
|
||||
private fun createAppOpenConfig(): AdConfig {
|
||||
val config = configData ?: run {
|
||||
AdLogger.e("配置数据为空,请先调用 initialize 初始化配置")
|
||||
throw IllegalStateException("配置数据为空,请先调用 initialize 初始化配置")
|
||||
}
|
||||
|
||||
val ctx = context ?: run {
|
||||
AdLogger.e("Context 未初始化")
|
||||
throw IllegalStateException("Context 未初始化")
|
||||
}
|
||||
|
||||
val channelConfig = try {
|
||||
getCurrentChannelConfig(config)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("获取渠道配置失败,使用默认配置", e)
|
||||
// 使用默认的natural配置
|
||||
config.natural
|
||||
}
|
||||
|
||||
return AdConfig.Builder(ctx, CONFIG_KEY_APP_OPEN)
|
||||
.setMaxDailyShow(channelConfig.appOpen.maxDailyShow)
|
||||
.setMaxDailyClick(channelConfig.appOpen.maxDailyClick)
|
||||
.setMinInterval(channelConfig.appOpen.minInterval.toLong())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建全屏原生广告配置(根据当前渠道)
|
||||
*/
|
||||
private fun createFullscreenNativeConfig(): AdConfig {
|
||||
val config = checkNotNull(configData) { "请先调用 initialize 初始化配置" }
|
||||
val ctx = checkNotNull(context) { "Context 未初始化" }
|
||||
val channelConfig = getCurrentChannelConfig(config)
|
||||
|
||||
return AdConfig.Builder(ctx, CONFIG_KEY_FULLSCREEN_NATIVE)
|
||||
.setMaxDailyShow(channelConfig.native.maxDailyShow)
|
||||
.setMaxDailyClick(channelConfig.native.maxDailyClick)
|
||||
.setMinInterval(channelConfig.native.minInterval.toLong())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前渠道的配置
|
||||
*/
|
||||
private fun getCurrentChannelConfig(config: AdConfigData): AdConfigData.ChannelConfig {
|
||||
return try {
|
||||
when (UserChannelController.getCurrentChannel()) {
|
||||
UserChannelController.UserChannelType.NATURAL -> config.natural
|
||||
UserChannelController.UserChannelType.PAID -> config.paid
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("获取用户渠道失败,使用默认natural配置", e)
|
||||
config.natural
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
package com.remax.bill.ads.ext
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.AppOpenAdController
|
||||
import com.remax.bill.ads.BannerAdController
|
||||
import com.remax.bill.ads.FullScreenNativeAdController
|
||||
import com.remax.bill.ads.InterstitialAdController
|
||||
import com.remax.bill.ads.NativeAdController
|
||||
import com.remax.bill.ads.bidding.AppOpenBiddingManager
|
||||
import com.remax.bill.ads.bidding.BannerBiddingManager
|
||||
import com.remax.bill.ads.bidding.BiddingWinner
|
||||
import com.remax.bill.ads.bidding.FullScreenNativeBiddingManager
|
||||
import com.remax.bill.ads.bidding.InterstitialBiddingManager
|
||||
import com.remax.bill.ads.bidding.NativeBiddingManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleAppOpenAdController
|
||||
import com.remax.bill.ads.pangle.PangleBannerAdController
|
||||
import com.remax.bill.ads.pangle.PangleFullScreenNativeAdController
|
||||
import com.remax.bill.ads.pangle.PangleInterstitialAdController
|
||||
import com.remax.bill.ads.pangle.PangleNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnBannerAdController
|
||||
import com.remax.bill.ads.topon.TopOnFullScreenNativeAdController
|
||||
import com.remax.bill.ads.topon.TopOnInterstitialAdController
|
||||
import com.remax.bill.ads.topon.TopOnSplashAdController
|
||||
import com.remax.bill.ads.topon.TopOnNativeAdController
|
||||
import com.remax.bill.ui.FullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.NativeAdStyle
|
||||
import com.remax.bill.ui.pangle.PangleFullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.pangle.PangleNativeAdStyle
|
||||
import com.remax.bill.ui.topon.ToponFullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.topon.ToponNativeAdStyle
|
||||
|
||||
/**
|
||||
* 广告展示扩展
|
||||
* 统一处理竞价逻辑,根据竞价结果调用对应平台的广告展示
|
||||
*/
|
||||
object AdShowExt {
|
||||
|
||||
// ==================== 开屏广告 ====================
|
||||
|
||||
/**
|
||||
* 展示开屏广告(带竞价)
|
||||
* @param activity Activity
|
||||
* @param onLoaded 广告加载完成回调
|
||||
* @return AdResult
|
||||
*/
|
||||
suspend fun showAppOpenAd(
|
||||
activity: Activity,
|
||||
onLoaded: ((Boolean) -> Unit)? = null
|
||||
): AdResult<Unit> {
|
||||
AdLogger.d("开屏广告竞价开始")
|
||||
|
||||
val winner = AppOpenBiddingManager.bidding(activity)
|
||||
AdLogger.d("开屏广告竞价结果: $winner")
|
||||
|
||||
return when (winner) {
|
||||
BiddingWinner.ADMOB -> {
|
||||
AdLogger.d("使用 AdMob 展示开屏广告")
|
||||
AppOpenAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.ADMOB_SPLASH_ID,
|
||||
onLoaded = onLoaded
|
||||
)
|
||||
}
|
||||
BiddingWinner.PANGLE -> {
|
||||
AdLogger.d("使用 Pangle 展示开屏广告")
|
||||
onLoaded?.invoke(true)
|
||||
PangleAppOpenAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.PANGLE_SPLASH_ID,
|
||||
onLoaded = onLoaded
|
||||
)
|
||||
}
|
||||
BiddingWinner.TOPON -> {
|
||||
AdLogger.d("使用 TopOn 展示开屏广告")
|
||||
onLoaded?.invoke(true)
|
||||
TopOnSplashAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.TOPON_SPLASH_ID,
|
||||
onLoaded = onLoaded
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 插页广告 ====================
|
||||
|
||||
/**
|
||||
* 展示插页广告(带竞价)
|
||||
* @param activity Activity
|
||||
* @return AdResult
|
||||
*/
|
||||
suspend fun showInterstitialAd(
|
||||
activity: Activity,
|
||||
ignoreFullNative: Boolean = false
|
||||
): AdResult<Unit> {
|
||||
AdLogger.d("插页广告竞价开始")
|
||||
|
||||
val winner = InterstitialBiddingManager.bidding(activity)
|
||||
AdLogger.d("插页广告竞价结果: $winner")
|
||||
|
||||
return when (winner) {
|
||||
BiddingWinner.ADMOB -> {
|
||||
AdLogger.d("使用 AdMob 展示插页广告")
|
||||
InterstitialAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.ADMOB_INTERSTITIAL_ID,
|
||||
ignoreFullNative = ignoreFullNative
|
||||
)
|
||||
}
|
||||
BiddingWinner.PANGLE -> {
|
||||
AdLogger.d("使用 Pangle 展示插页广告")
|
||||
PangleInterstitialAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.PANGLE_INTERSTITIAL_ID,
|
||||
ignoreFullNative = ignoreFullNative
|
||||
)
|
||||
}
|
||||
BiddingWinner.TOPON -> {
|
||||
AdLogger.d("使用 TopOn 展示插页广告")
|
||||
TopOnInterstitialAdController.getInstance().showAd(
|
||||
activity,
|
||||
BuildConfig.TOPON_INTERSTITIAL_ID,
|
||||
ignoreFullNative = ignoreFullNative
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 原生广告 ====================
|
||||
|
||||
/**
|
||||
* 在容器中展示原生广告(带竞价)
|
||||
* @param context Context
|
||||
* @param container 广告容器
|
||||
* @param style 广告样式
|
||||
* @return Boolean 是否展示成功
|
||||
*/
|
||||
suspend fun showNativeAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
style: NativeAdStyle// admob的样式
|
||||
): Boolean {
|
||||
AdLogger.d("原生广告竞价开始")
|
||||
|
||||
val winner = NativeBiddingManager.bidding(context)
|
||||
AdLogger.d("原生广告竞价结果: $winner")
|
||||
|
||||
return when (winner) {
|
||||
BiddingWinner.ADMOB -> {
|
||||
AdLogger.d("使用 AdMob 展示原生广告")
|
||||
NativeAdController.getInstance().showAdInContainer(
|
||||
context,
|
||||
container,
|
||||
style,
|
||||
BuildConfig.ADMOB_NATIVE_ID
|
||||
)
|
||||
}
|
||||
BiddingWinner.PANGLE -> {
|
||||
AdLogger.d("使用 Pangle 展示原生广告")
|
||||
PangleNativeAdController.getInstance().showAdInContainer(
|
||||
context,
|
||||
container,
|
||||
style = if(style == NativeAdStyle.STANDARD) PangleNativeAdStyle.STANDARD else PangleNativeAdStyle.LARGE,
|
||||
adUnitId = BuildConfig.PANGLE_NATIVE_ID
|
||||
)
|
||||
}
|
||||
BiddingWinner.TOPON -> {
|
||||
AdLogger.d("使用 TopOn 展示原生广告")
|
||||
TopOnNativeAdController.getInstance().showAdInContainer(
|
||||
context,
|
||||
container,
|
||||
style = if(style == NativeAdStyle.STANDARD) ToponNativeAdStyle.STANDARD else ToponNativeAdStyle.LARGE,
|
||||
placementId = BuildConfig.TOPON_NATIVE_ID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 全屏原生广告 ====================
|
||||
|
||||
/**
|
||||
* 在容器中展示全屏原生广告(带竞价)
|
||||
* @param context Context
|
||||
* @param container 广告容器
|
||||
* @param style 广告样式
|
||||
* @return Boolean 是否展示成功
|
||||
*/
|
||||
suspend fun showFullScreenNativeAdInContainer(
|
||||
activity: Activity,
|
||||
showInterstitial: Boolean = true
|
||||
): AdResult<Unit> {
|
||||
AdLogger.d("全屏原生广告竞价开始")
|
||||
|
||||
val winner = FullScreenNativeBiddingManager.bidding(activity)
|
||||
AdLogger.d("全屏原生广告竞价结果: $winner")
|
||||
|
||||
return when (winner) {
|
||||
BiddingWinner.ADMOB -> {
|
||||
AdLogger.d("使用 AdMob 展示全屏原生广告")
|
||||
FullScreenNativeAdActivity.start(activity,showInterstitial)
|
||||
}
|
||||
BiddingWinner.PANGLE -> {
|
||||
AdLogger.d("使用 Pangle 展示全屏原生广告")
|
||||
PangleFullScreenNativeAdActivity.start(activity,showInterstitial)
|
||||
}
|
||||
BiddingWinner.TOPON -> {
|
||||
AdLogger.d("使用 TopOn 展示全屏原生广告")
|
||||
ToponFullScreenNativeAdActivity.start(activity,showInterstitial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Banner广告 ====================
|
||||
|
||||
/**
|
||||
* 展示Banner广告(带竞价)
|
||||
* @param activity Activity
|
||||
* @param container 广告容器
|
||||
* @return AdResult
|
||||
*/
|
||||
suspend fun showBannerAd(
|
||||
activity: Activity,
|
||||
container: ViewGroup
|
||||
): AdResult<View> {
|
||||
AdLogger.d("Banner广告竞价开始")
|
||||
|
||||
val winner = BannerBiddingManager.bidding(activity)
|
||||
AdLogger.d("Banner广告竞价结果: $winner")
|
||||
|
||||
return when (winner) {
|
||||
BiddingWinner.ADMOB -> {
|
||||
AdLogger.d("使用 AdMob 展示Banner广告")
|
||||
BannerAdController.getInstance().showAd(
|
||||
activity,
|
||||
container,
|
||||
BuildConfig.ADMOB_BANNER_ID
|
||||
)
|
||||
}
|
||||
BiddingWinner.PANGLE -> {
|
||||
AdLogger.d("使用 Pangle 展示Banner广告")
|
||||
PangleBannerAdController.getInstance().showAd(
|
||||
activity,
|
||||
container,
|
||||
BuildConfig.PANGLE_BANNER_ID
|
||||
)
|
||||
}
|
||||
BiddingWinner.TOPON -> {
|
||||
AdLogger.d("使用 TopOn 展示Banner广告")
|
||||
TopOnBannerAdController.getInstance().showAd(
|
||||
activity,
|
||||
container,
|
||||
BuildConfig.TOPON_BANNER_ID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 广告展示状态检查 ====================
|
||||
|
||||
/**
|
||||
* 检查是否有任何插页或全屏原生广告正在展示
|
||||
* @return Boolean
|
||||
*/
|
||||
fun isAnyInterstitialOrFullScreenNativeShowing(): Boolean {
|
||||
return InterstitialAdController.getInstance().isAdShowing() ||
|
||||
FullScreenNativeAdController.getInstance().isAdShowing() ||
|
||||
PangleInterstitialAdController.getInstance().isAdShowing() ||
|
||||
PangleFullScreenNativeAdController.getInstance().isAdShowing() ||
|
||||
TopOnInterstitialAdController.getInstance().isAdShowing() ||
|
||||
TopOnFullScreenNativeAdController.getInstance().isAdShowing()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package com.remax.bill.ads.interceptor
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.base.ext.KvBoolDelegate
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfig
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
|
||||
/**
|
||||
* 广告拦截器接口
|
||||
*/
|
||||
interface AdInterceptor {
|
||||
/**
|
||||
* 拦截广告操作
|
||||
* @param context 上下文
|
||||
* @param adConfig 广告配置
|
||||
* @return AdResult 拦截结果
|
||||
*/
|
||||
suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示次数限制拦截器
|
||||
*/
|
||||
class ShowCountLimitInterceptor : AdInterceptor {
|
||||
companion object {
|
||||
private const val TAG = "AdModule"
|
||||
}
|
||||
|
||||
override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit> {
|
||||
// 检查日展示次数
|
||||
val dailyShow = adConfig.getDailyShowCount()
|
||||
if (dailyShow >= adConfig.getMaxDailyShow()) {
|
||||
AdLogger.w("[${adConfig.getConfigKey()}] 超出每日展示限制: $dailyShow/${adConfig.getMaxDailyShow()}")
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -1,
|
||||
message = "超出每日展示限制"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示间隔限制拦截器
|
||||
*/
|
||||
class ShowIntervalLimitInterceptor : AdInterceptor {
|
||||
companion object {
|
||||
private const val TAG = "AdModule"
|
||||
}
|
||||
|
||||
override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit> {
|
||||
// 检查展示间隔
|
||||
val interval = adConfig.getLastShowInterval()
|
||||
|
||||
// 如果间隔为负数或异常值,说明系统时间被修改过,重置时间记录
|
||||
if (interval < 0) {
|
||||
AdLogger.w("[${adConfig.getConfigKey()}] 检测到系统时间异常,重置展示时间记录")
|
||||
adConfig.resetLastShowTime()
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
if (interval < adConfig.getMinInterval()) {
|
||||
AdLogger.w("[${adConfig.getConfigKey()}] 展示间隔过短: ${interval}s < ${adConfig.getMinInterval()}s")
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -2,
|
||||
message = "展示间隔过短,请稍后再试"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击限制拦截器
|
||||
*/
|
||||
class ClickLimitInterceptor : AdInterceptor {
|
||||
companion object {
|
||||
private const val TAG = "AdModule"
|
||||
}
|
||||
|
||||
override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit> {
|
||||
// 检查日点击次数
|
||||
val dailyClick = adConfig.getDailyClickCount()
|
||||
if (dailyClick >= adConfig.getMaxDailyClick()) {
|
||||
AdLogger.w("[${adConfig.getConfigKey()}] 超出每日点击限制: $dailyClick/${adConfig.getMaxDailyClick()}")
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -3,
|
||||
message = "超出每日点击限制"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局广告开关拦截器
|
||||
* 使用临时变量控制全局广告的开启和关闭
|
||||
*/
|
||||
class GlobalAdSwitchInterceptor : AdInterceptor {
|
||||
companion object {
|
||||
private const val TAG = "GlobalAdSwitch"
|
||||
|
||||
private var _isGlobalAdEnabled by KvBoolDelegate("GlobalAdSwitchInterceptor_isGlobalAdEnabledDefault", true)
|
||||
|
||||
/**
|
||||
* 开启全局广告
|
||||
*/
|
||||
fun enableGlobalAd() {
|
||||
_isGlobalAdEnabled = true
|
||||
AdLogger.d("[$TAG] 全局广告已开启")
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭全局广告
|
||||
*/
|
||||
fun disableGlobalAd() {
|
||||
_isGlobalAdEnabled = false
|
||||
AdLogger.d("[$TAG] 全局广告已关闭")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前全局广告状态
|
||||
*/
|
||||
fun isGlobalAdEnabled(): Boolean = _isGlobalAdEnabled
|
||||
|
||||
/**
|
||||
* 切换全局广告状态
|
||||
*/
|
||||
fun toggleGlobalAd() {
|
||||
_isGlobalAdEnabled = !_isGlobalAdEnabled
|
||||
AdLogger.d("[$TAG] 全局广告状态已切换为: ${if (_isGlobalAdEnabled) "开启" else "关闭"}")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit> {
|
||||
if (!_isGlobalAdEnabled) {
|
||||
AdLogger.w("[${adConfig.getConfigKey()}] 全局广告已关闭,跳过广告展示")
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全局广告已关闭"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截器链
|
||||
*/
|
||||
class InterceptorChain(
|
||||
private val interceptors: List<AdInterceptor>
|
||||
) : AdInterceptor {
|
||||
override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult<Unit> {
|
||||
interceptors.forEach { interceptor ->
|
||||
when (val result = interceptor.intercept(context, adConfig)) {
|
||||
is AdResult.Failure -> {
|
||||
// 将拦截器信息拼接到message中
|
||||
val interceptorName = interceptor::class.simpleName ?: "Unknown"
|
||||
val interceptorDetails = getInterceptorDetails(interceptor, adConfig)
|
||||
val enhancedMessage = "[Interceptor: $interceptorName, Details: $interceptorDetails]"
|
||||
|
||||
val enhancedException = AdException(
|
||||
code = result.error.code,
|
||||
message = enhancedMessage,
|
||||
cause = result.error.cause
|
||||
)
|
||||
return AdResult.Failure(enhancedException)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
}
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拦截器的详细信息
|
||||
*/
|
||||
private fun getInterceptorDetails(interceptor: AdInterceptor, adConfig: AdConfig): String {
|
||||
return when (interceptor) {
|
||||
is GlobalAdSwitchInterceptor -> "Global ad switch is disabled"
|
||||
is ShowCountLimitInterceptor -> "Daily show limit exceeded: ${adConfig.getDailyShowCount()}/${adConfig.getMaxDailyShow()}"
|
||||
is ShowIntervalLimitInterceptor -> "Show interval too short: ${adConfig.getLastShowInterval()}s < ${adConfig.getMinInterval()}s"
|
||||
is ClickLimitInterceptor -> "Daily click limit exceeded: ${adConfig.getDailyClickCount()}/${adConfig.getMaxDailyClick()}"
|
||||
else -> "Unknown interceptor"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.remax.bill.ads.log
|
||||
|
||||
import android.util.Log
|
||||
import com.remax.bill.BuildConfig
|
||||
|
||||
/**
|
||||
* 广告日志工具类
|
||||
* 提供统一的日志输出控制和管理
|
||||
*/
|
||||
object AdLogger {
|
||||
private const val TAG = "AdModule"
|
||||
|
||||
/**
|
||||
* 日志开关,默认为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,555 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.bytedance.sdk.openadsdk.api.open.PAGAppOpenAd
|
||||
import com.bytedance.sdk.openadsdk.api.open.PAGAppOpenAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.open.PAGAppOpenAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.open.PAGAppOpenRequest
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Pangle开屏广告控制器
|
||||
* 专门处理开屏广告的加载和显示
|
||||
* 参考文档: https://www.pangleglobal.com/integration/android-App-Open-Ads
|
||||
*
|
||||
* 预加载说明:
|
||||
* - Pangle SDK会在广告显示或关闭后自动开始新的广告请求(自动预加载后续广告)
|
||||
* - 但第一次显示时,如果没有预加载,可能需要等待加载时间
|
||||
* - 建议在应用启动时调用preloadAd()预加载第一个广告,以提升首次显示体验
|
||||
* - 后续广告由SDK自动处理,无需手动预加载
|
||||
*/
|
||||
class PangleAppOpenAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("pangle_app_open_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_app_open_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_app_open_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_app_open_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_app_open_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_app_open_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("pangle_app_open_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleAppOpenAdController"
|
||||
private const val LOAD_TIMEOUT = 7000L // 加载超时时间7秒
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleAppOpenAdController? = null
|
||||
|
||||
fun getInstance(): PangleAppOpenAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleAppOpenAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前加载的广告请求(推荐作为Activity的成员变量)
|
||||
private var currentAppOpenRequest: PAGAppOpenRequest? = null
|
||||
|
||||
// 当前加载的广告对象
|
||||
private var currentAppOpenAd: PAGAppOpenAd? = null
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 预加载开屏广告
|
||||
* 建议在应用启动时调用此方法预加载第一个广告,以提升首次显示体验
|
||||
* 注意:后续广告由Pangle SDK自动预加载,无需手动调用
|
||||
*
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
return loadAd(context, adUnitId)
|
||||
}
|
||||
|
||||
fun hasCachedAd(): Boolean {
|
||||
return currentAppOpenAd != null
|
||||
}
|
||||
|
||||
fun getCurrentAd(): PAGAppOpenAd? = currentAppOpenAd
|
||||
|
||||
/**
|
||||
* 基础广告加载方法
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Pangle开屏广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 创建PAGAppOpenRequest对象(推荐作为Activity的成员变量)
|
||||
val request = PAGAppOpenRequest(context)
|
||||
request.setTimeout(LOAD_TIMEOUT.toInt()) // 设置加载超时时间
|
||||
|
||||
currentAppOpenRequest = request
|
||||
|
||||
// 加载广告并注册回调
|
||||
PAGAppOpenAd.loadAd(adUnitId, request, object : PAGAppOpenAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGAppOpenAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle开屏广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
currentAppOpenAd = ad
|
||||
continuation.resume(AdResult.Success(Unit))
|
||||
}
|
||||
|
||||
override fun onError(model:PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle开屏广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s",
|
||||
adUnitId, loadTime, code, message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
|
||||
currentAppOpenAd = null
|
||||
continuation.resume(AdResult.Failure(
|
||||
createAdException("广告加载失败: ${message} (code: ${code})")
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示开屏广告
|
||||
* @param activity Activity上下文
|
||||
* @param adUnitId 广告位ID
|
||||
* @param onLoaded 加载回调
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
adUnitId: String,
|
||||
onLoaded: ((isSuc: Boolean) -> Unit)? = null
|
||||
): AdResult<Unit> {
|
||||
// 累积触发广告展示次数统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Pangle开屏广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getAppOpenConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle开屏广告累积展示失败次数: $totalShowFailCount")
|
||||
onLoaded?.invoke(false)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message,
|
||||
)
|
||||
)
|
||||
return interceptResult
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
val adResult = try {
|
||||
// 如果当前没有加载的广告,先加载
|
||||
if (currentAppOpenAd == null) {
|
||||
AdLogger.d("当前没有广告,立即加载Pangle开屏广告,广告位ID: %s", adUnitId)
|
||||
val loadResult = loadAd(activity, adUnitId)
|
||||
if (loadResult is AdResult.Failure) {
|
||||
onLoaded?.invoke(false)
|
||||
return loadResult
|
||||
}
|
||||
}
|
||||
|
||||
val ad = currentAppOpenAd
|
||||
if (ad != null) {
|
||||
AdLogger.d("显示Pangle开屏广告,广告位ID: %s", adUnitId)
|
||||
onLoaded?.invoke(true)
|
||||
|
||||
// 显示广告
|
||||
val result = showAdInternal(activity, ad, adUnitId)
|
||||
|
||||
// 清空当前广告,Pangle SDK会自动加载下一个
|
||||
currentAppOpenAd = null
|
||||
|
||||
result
|
||||
} else {
|
||||
onLoaded?.invoke(false)
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示Pangle开屏广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
}
|
||||
|
||||
return adResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
private suspend fun showAdInternal(
|
||||
activity: Activity,
|
||||
appOpenAd: PAGAppOpenAd,
|
||||
adUnitId: String
|
||||
): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// 临时变量保存收益数据
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
var currentPlacement: String? = null
|
||||
var currentRevenueAdUnit: String? = null
|
||||
|
||||
// 注册广告事件回调(需要在显示前注册)
|
||||
appOpenAd.setAdInteractionListener(object : PAGAppOpenAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
val pagRevenueInfo: PAGRevenueInfo? = appOpenAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
currentPlacement = ecpmInfo?.placement
|
||||
currentRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
AdLogger.d(
|
||||
"Pangle开屏广告eCPM信息: revenue=%s, currency=%s, adn=%s, placement=%s, adUnit=%s",
|
||||
ecpmInfo?.revenue?.toString() ?: "",
|
||||
currentCurrency ?: "",
|
||||
currentAdSource ?: "",
|
||||
currentPlacement ?: "",
|
||||
currentRevenueAdUnit ?: ""
|
||||
)
|
||||
val impressionValue = revenueUsd
|
||||
|
||||
AdLogger.d("Pangle开屏广告开始显示")
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Pangle开屏广告累积展示次数: $totalShowCount")
|
||||
|
||||
AdConfigManager.getAppOpenConfig().recordShow()
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = adUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = currentPlacement,
|
||||
ecpmAdUnitId = currentRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull()?.toLong() ?: 0L
|
||||
AdLogger.d(
|
||||
"Pangle开屏广告收益上报(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
currentRevenueAdUnit ?: adUnitId,
|
||||
currentPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle开屏广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Pangle开屏广告累积点击次数: $totalClickCount")
|
||||
AdLogger.d(
|
||||
"Pangle开屏广告点击时收益数据: %s",
|
||||
if (currentRevenueUsd != null) {
|
||||
"value=${currentRevenueUsd}, currency=${currentCurrency ?: ""}" }
|
||||
else {
|
||||
"暂无收益数据"
|
||||
}
|
||||
)
|
||||
|
||||
AdConfigManager.getAppOpenConfig().recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (appOpenAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
totalCloseCount++
|
||||
AdLogger.d("Pangle开屏广告关闭")
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (appOpenAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
val result = AdResult.Success(Unit)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(pagErrorModel: PAGErrorModel) {
|
||||
super.onAdShowFailed(pagErrorModel)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle开屏广告显示失败: code=%d, message=%s",
|
||||
pagErrorModel.errorCode,
|
||||
pagErrorModel.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to pagErrorModel.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 显示广告(必须在主线程调用)
|
||||
if (!appOpenAd.isReady) {
|
||||
AdLogger.w("Pangle开屏广告未就绪,无法显示")
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "app_open_not_ready",
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
val result = AdResult.Failure(createAdException("广告未准备就绪"))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
try {
|
||||
appOpenAd.show(activity)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示Pangle开屏广告异常", e)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
val result = AdResult.Failure(createAdException("显示失败: ${e.message}", e))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param adUnitId 广告位ID
|
||||
* @param valueUsd 收益值(美元)
|
||||
* @param currencyCode 货币代码
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "Splash"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle开屏广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
currentAppOpenAd = null
|
||||
currentAppOpenRequest = null
|
||||
AdLogger.d("Pangle开屏广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("Pangle开屏广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "Splash"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,637 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.bytedance.sdk.openadsdk.api.banner.PAGBannerAd
|
||||
import com.bytedance.sdk.openadsdk.api.banner.PAGBannerAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.banner.PAGBannerAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.banner.PAGBannerRequest
|
||||
import com.bytedance.sdk.openadsdk.api.banner.PAGBannerSize
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Pangle Banner广告控制器
|
||||
* 提供标准Banner广告显示功能
|
||||
* 参考文档: https://www.pangleglobal.com/integration/android-banner-ads-sdk
|
||||
*
|
||||
* 注意:Pangle仅支持两种Banner尺寸:300x250(dp)和320x50(dp)
|
||||
*/
|
||||
class PangleBannerAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("pangle_banner_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_banner_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_banner_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_banner_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_banner_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_banner_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("pangle_banner_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleBannerAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L // 1小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
// Pangle支持的Banner尺寸
|
||||
private const val BANNER_WIDTH_320 = 320 // 320x50标准Banner
|
||||
private const val BANNER_WIDTH_300 = 300 // 300x250矩形Banner
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleBannerAdController? = null
|
||||
|
||||
fun getInstance(): PangleBannerAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleBannerAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedBannerAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 当前显示的Banner广告
|
||||
private var currentBannerAd: PAGBannerAd? = null
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的Banner广告数据类
|
||||
*/
|
||||
private data class CachedBannerAd(
|
||||
val ad: PAGBannerAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Banner尺寸对象
|
||||
* @param width Banner宽度(dp),支持320或300
|
||||
* @return PAGBannerSize对象
|
||||
*/
|
||||
private fun createBannerSize(width: Int = BANNER_WIDTH_320): PAGBannerSize {
|
||||
return when (width) {
|
||||
BANNER_WIDTH_300 -> PAGBannerSize.BANNER_W_300_H_250
|
||||
else -> PAGBannerSize.BANNER_W_320_H_50
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载Banner广告
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @param width Banner宽度(dp),默认320
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null, width: Int = BANNER_WIDTH_320): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "Banner全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_BANNER_ID
|
||||
return loadAdToCache(context, finalAdUnitId, width)
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String, width: Int = BANNER_WIDTH_320): PAGBannerAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Pangle Banner广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 创建PAGBannerSize对象
|
||||
val bannerSize = createBannerSize(width)
|
||||
|
||||
// 创建PAGBannerRequest对象(推荐作为Activity的成员变量)
|
||||
val request = PAGBannerRequest(context,bannerSize)
|
||||
|
||||
// 加载广告并注册回调
|
||||
PAGBannerAd.loadAd(adUnitId, request, object : PAGBannerAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGBannerAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle Banner广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(ad)
|
||||
}
|
||||
|
||||
override fun onError(model :PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle Banner广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s",
|
||||
adUnitId, loadTime, code, message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String, width: Int = BANNER_WIDTH_320): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val bannerAd = loadAd(context, adUnitId, width)
|
||||
if (bannerAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedBannerAd(bannerAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("Pangle Banner广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle Banner loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示Banner广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @param width Banner宽度(dp),默认320
|
||||
*/
|
||||
suspend fun showAd(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
adUnitId: String? = null,
|
||||
width: Int = BANNER_WIDTH_320
|
||||
): AdResult<View> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_BANNER_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Pangle Banner广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getBannerConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle Banner广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.w("Pangle Banner广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载Pangle Banner广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(context, finalAdUnitId, width)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
if(!cachedAd.ad.isReady){
|
||||
throw IllegalArgumentException("banner_not_ready")
|
||||
}
|
||||
AdLogger.d("使用缓存中的Pangle Banner广告,广告位ID: %s", finalAdUnitId)
|
||||
|
||||
// 2. 获取Banner View并添加到容器
|
||||
val bannerView = cachedAd.ad.getBannerView()
|
||||
if (bannerView != null) {
|
||||
// 清空容器
|
||||
container.removeAllViews()
|
||||
|
||||
// 注册广告事件回调(需要在显示前注册)
|
||||
val bannerAd = cachedAd.ad
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
var currentPlacement: String? = null
|
||||
var currentRevenueAdUnit: String? = null
|
||||
|
||||
bannerAd.setAdInteractionListener(object : PAGBannerAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle Banner广告开始显示")
|
||||
val pagRevenueInfo: PAGRevenueInfo? = bannerAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
currentPlacement = ecpmInfo?.placement
|
||||
currentRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
val impressionValue = revenueUsd
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Pangle Banner广告累积展示次数: $totalShowCount")
|
||||
|
||||
AdConfigManager.getBannerConfig().recordShow()
|
||||
|
||||
// 上报展示事件
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = finalAdUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = currentPlacement,
|
||||
ecpmAdUnitId = currentRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull()?.toLong() ?: 0L
|
||||
AdLogger.d(
|
||||
"Pangle Banner广告收益上报(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
currentRevenueAdUnit ?: finalAdUnitId,
|
||||
currentPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle Banner广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Pangle Banner广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getBannerConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (bannerAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle Banner广告关闭")
|
||||
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (bannerAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(model: PAGErrorModel) {
|
||||
super.onAdShowFailed(model)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle Banner广告显示失败: code=%d, message=%s",
|
||||
model.errorCode,
|
||||
model.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to model.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加到容器,设置居中布局
|
||||
val layoutParams = when (container) {
|
||||
is FrameLayout -> {
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
bannerView.layoutParams = layoutParams
|
||||
container.addView(bannerView)
|
||||
|
||||
// 保存当前广告引用
|
||||
currentBannerAd = bannerAd
|
||||
|
||||
AdResult.Success(bannerView)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("Banner View获取失败"))
|
||||
}
|
||||
} else {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle Banner广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "No fill"
|
||||
)
|
||||
)
|
||||
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty(),
|
||||
"ad_source" to "Pangle"
|
||||
)
|
||||
)
|
||||
AdLogger.e("显示Pangle Banner广告失败", e)
|
||||
container.removeAllViews()
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -1,
|
||||
message = "显示Pangle Banner广告异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedBannerAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前Banner广告View
|
||||
*/
|
||||
fun getCurrentBannerView(): View? {
|
||||
return currentBannerAd?.getBannerView()
|
||||
}
|
||||
|
||||
fun getCurrentAd(): PAGBannerAd? = currentBannerAd
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(): Boolean {
|
||||
return currentBannerAd != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { it.ad.destroy() }
|
||||
adCachePool.clear()
|
||||
}
|
||||
currentBannerAd?.destroy()
|
||||
currentBannerAd = null
|
||||
AdLogger.d("Pangle Banner广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("Pangle Banner广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "Banner"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "Banner"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle Banner广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,546 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAd
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeRequest
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.pangle.PangleFullScreenNativeAdView
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Pangle全屏原生广告控制器
|
||||
* 参考文档:https://www.pangleglobal.com/integration/android-native-ads
|
||||
*/
|
||||
class PangleFullScreenNativeAdController private constructor() {
|
||||
|
||||
// 累积点击/展示等统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("pangle_full_native_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_full_native_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_full_native_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_full_native_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_full_native_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_full_native_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("pangle_full_native_total_shows", 0)
|
||||
|
||||
private val nativeAdView = PangleFullScreenNativeAdView()
|
||||
|
||||
// 全屏原生广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleFullScreenNative"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleFullScreenNativeAdController? = null
|
||||
|
||||
fun getInstance(): PangleFullScreenNativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleFullScreenNativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CachedFullScreenNativeAd(
|
||||
val ad: PAGNativeAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
private val adCachePool = mutableListOf<CachedFullScreenNativeAd>()
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "FullNative"
|
||||
)
|
||||
data.putAll(params)
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeEvent(
|
||||
adUnitId: String = "",
|
||||
adSource: String? = "Pangle",
|
||||
valueUsd: Double? = null,
|
||||
currencyCode: String? = null
|
||||
) {
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to runCatching { PositionGet.get() }.getOrDefault(""),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adSource ?: "Pangle"),
|
||||
"value" to (valueUsd ?: 0.0),
|
||||
"currency" to (currencyCode ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全屏原生广告全局开关已关闭"
|
||||
)
|
||||
)
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_FULL_NATIVE_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
adUnitId: String? = null
|
||||
): AdResult<Unit> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_FULL_NATIVE_ID
|
||||
|
||||
totalShowTriggerCount++
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getFullscreenNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
return try {
|
||||
nativeAdView.createFullScreenLoadingView(context, container)
|
||||
|
||||
when (val result = getAd(context, finalAdUnitId)) {
|
||||
is AdResult.Success -> {
|
||||
val nativeAd = result.data
|
||||
|
||||
if(!nativeAd.isReady){
|
||||
throw IllegalArgumentException("full_native_not_ready")
|
||||
}
|
||||
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
var currentPlacement: String? = null
|
||||
var currentRevenueAdUnit: String? = null
|
||||
|
||||
val bindSuccess = nativeAdView.bindFullScreenNativeAdToContainer(
|
||||
context = context,
|
||||
container = container,
|
||||
nativeAd = nativeAd,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
interactionListener = object : PAGNativeAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle全屏原生广告开始显示")
|
||||
val pagRevenueInfo: PAGRevenueInfo? = nativeAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
currentPlacement = ecpmInfo?.placement
|
||||
currentRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
val impressionValue = revenueUsd
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
totalShowCount++
|
||||
AdConfigManager.getFullscreenNativeConfig().recordShow()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = finalAdUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = currentPlacement,
|
||||
ecpmAdUnitId = currentRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull()?.toLong() ?: 0L
|
||||
AdLogger.d(
|
||||
"Pangle全屏原生收益(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
currentRevenueAdUnit ?: finalAdUnitId,
|
||||
currentPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle全屏原生广告被点击")
|
||||
totalClickCount++
|
||||
AdConfigManager.getFullscreenNativeConfig().recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (nativeAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle全屏原生广告关闭")
|
||||
closeEvent(
|
||||
adUnitId = finalAdUnitId,
|
||||
adSource = currentAdSource,
|
||||
valueUsd = currentRevenueUsd,
|
||||
currencyCode = currentCurrency
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(error: PAGErrorModel) {
|
||||
super.onAdShowFailed(error)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle全屏原生广告显示失败: code=%d, message=%s",
|
||||
error.errorCode,
|
||||
error.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to error.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (bindSuccess) {
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "bind_failed"
|
||||
)
|
||||
)
|
||||
AdResult.Failure(createAdException("广告绑定失败"))
|
||||
}
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
AdResult.Failure(result.error)
|
||||
}
|
||||
AdResult.Loading -> AdResult.Loading
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdLogger.e("Pangle全屏原生广告展示异常", e)
|
||||
AdResult.Failure(createAdException("显示异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createInteractionListener(adUnitId: String): PAGNativeAdInteractionCallback {
|
||||
return object : PAGNativeAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle全屏原生广告开始显示")
|
||||
AdConfigManager.getFullscreenNativeConfig().recordShow()
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle全屏原生广告被点击")
|
||||
totalClickCount++
|
||||
AdConfigManager.getFullscreenNativeConfig().recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to "Pangle",
|
||||
"value" to 0.0,
|
||||
"currency" to "USD"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle全屏原生广告关闭")
|
||||
closeEvent(adUnitId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getAd(context: Context, adUnitId: String): AdResult<PAGNativeAd> {
|
||||
var cachedAd = getCachedAd(adUnitId)
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载Pangle全屏原生广告,广告位ID: %s", adUnitId)
|
||||
loadAdToCache(context, adUnitId)
|
||||
cachedAd = getCachedAd(adUnitId)
|
||||
}
|
||||
return if (cachedAd != null) {
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("load ad fail"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCachedAd(adUnitId: String): CachedFullScreenNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) adCachePool.removeAt(index) else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun peekCachedAd(adUnitId: String): PAGNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.firstOrNull { it.adUnitId == adUnitId && !it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满(需要同步访问)
|
||||
val currentCount = synchronized(adCachePool) {
|
||||
adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
if (currentCount >= DEFAULT_CACHE_SIZE_PER_AD_UNIT) {
|
||||
AdLogger.d("广告位 %s 缓存已满", adUnitId)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
val ad = loadAd(context, adUnitId)
|
||||
if (ad != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedFullScreenNativeAd(ad, adUnitId))
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("load ad fail"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle全屏原生广告缓存加载异常", e)
|
||||
AdResult.Failure(createAdException("加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAd(adUnitId: String? = null): PAGNativeAd? {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_FULL_NATIVE_ID
|
||||
return peekCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): PAGNativeAd? {
|
||||
totalLoadCount++
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
val request = PAGNativeRequest(context)
|
||||
PAGNativeAd.loadAd(adUnitId, request, object : PAGNativeAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGNativeAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle全屏原生广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
continuation.resume(ad)
|
||||
}
|
||||
|
||||
override fun onError(model: PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle全屏原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s", adUnitId, loadTime, code, message)
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun hasCachedAd(adUnitId: String? = null): Boolean {
|
||||
synchronized(adCachePool) {
|
||||
return if (adUnitId != null) {
|
||||
adCachePool.any { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
} else {
|
||||
adCachePool.any { !it.isExpired() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "FullNative"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle全屏原生广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("Pangle全屏原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告是否正在显示的状态
|
||||
* @return true 如果全屏原生广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
@file:Suppress("RedundantNullableInit")
|
||||
|
||||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.bytedance.sdk.openadsdk.api.interstitial.PAGInterstitialAd
|
||||
import com.bytedance.sdk.openadsdk.api.interstitial.PAGInterstitialAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.interstitial.PAGInterstitialAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.interstitial.PAGInterstitialRequest
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.FullScreenNativeAdController
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.ext.AdShowExt
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.FullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Pangle插页广告控制器
|
||||
* 专门处理插页广告的加载和显示
|
||||
* 参考文档: https://www.pangleglobal.com/integration/android-interstitial-ads
|
||||
*
|
||||
* 预加载说明:
|
||||
* - Pangle SDK会在广告显示或关闭后自动开始新的广告请求(自动预加载后续广告)
|
||||
* - 但第一次显示时,如果没有预加载,可能需要等待加载时间
|
||||
* - 建议在应用启动时调用preloadAd()预加载第一个广告,以提升首次显示体验
|
||||
* - 后续广告由SDK自动处理,无需手动预加载
|
||||
*/
|
||||
class PangleInterstitialAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("pangle_interstitial_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_interstitial_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_interstitial_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_interstitial_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_interstitial_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_interstitial_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("pangle_interstitial_ad_total_shows", 0)
|
||||
|
||||
// 插页广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleInterstitialAdController"
|
||||
private const val LOAD_TIMEOUT = 7000L // 加载超时时间7秒
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleInterstitialAdController? = null
|
||||
|
||||
fun getInstance(): PangleInterstitialAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleInterstitialAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前加载的广告请求(推荐作为Activity的成员变量)
|
||||
private var currentInterstitialRequest: PAGInterstitialRequest? = null
|
||||
|
||||
// 当前加载的广告对象
|
||||
private var currentInterstitialAd: PAGInterstitialAd? = null
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 预加载插页广告
|
||||
* 建议在应用启动时调用此方法预加载第一个广告,以提升首次显示体验
|
||||
* 注意:后续广告由Pangle SDK自动预加载,无需手动调用
|
||||
*
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "插页全局广告已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_INTERSTITIAL_ID
|
||||
|
||||
// 检查当前缓存是否存在且广告已就绪
|
||||
val cachedAd = getCurrentAd()
|
||||
if (cachedAd != null && cachedAd.isReady) {
|
||||
AdLogger.d("Pangle插页广告已有有效缓存且已就绪,广告位ID: %s,跳过加载", finalAdUnitId)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return loadAd(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Pangle插页广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 创建PAGInterstitialRequest对象(推荐作为Activity的成员变量)
|
||||
val request = PAGInterstitialRequest(context)
|
||||
currentInterstitialRequest = request
|
||||
|
||||
// 加载广告并注册回调
|
||||
PAGInterstitialAd.loadAd(adUnitId, request, object : PAGInterstitialAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGInterstitialAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle插页广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
currentInterstitialAd = ad
|
||||
continuation.resume(AdResult.Success(Unit))
|
||||
}
|
||||
|
||||
override fun onError(model: PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle插页广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s",
|
||||
adUnitId, loadTime, code, message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
|
||||
currentInterstitialAd = null
|
||||
continuation.resume(AdResult.Failure(
|
||||
createAdException("广告加载失败: ${message} (code: ${code})")
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示插页广告
|
||||
* @param activity Activity上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @param ignoreFullNative 是否忽略全屏原生广告
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
adUnitId: String? = null,
|
||||
ignoreFullNative: Boolean = false
|
||||
): AdResult<Unit> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_INTERSTITIAL_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Pangle插页广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getInterstitialConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle插页广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
return interceptResult
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
// 是否加载全屏原生
|
||||
val interval = AdConfigManager.getFullscreenNativeAfterInterstitialCount()
|
||||
val todayShowInter = AdConfigManager.getInterstitialConfig().getDailyShowCount()
|
||||
val needShowNativeFull = interval > 0 && todayShowInter > 0 && todayShowInter % interval == 0
|
||||
AdLogger.d("当日已展示${todayShowInter}个插页,每显示${interval}个插页将显示原生,下一个是否显示全屏原生${needShowNativeFull}")
|
||||
|
||||
if(!ignoreFullNative && needShowNativeFull){
|
||||
return AdShowExt.showFullScreenNativeAdInContainer(activity,true)
|
||||
}
|
||||
|
||||
return try {
|
||||
// 1. 如果当前没有加载的广告,先加载
|
||||
if (currentInterstitialAd == null) {
|
||||
// 插页阻塞loading
|
||||
ADLoadingDialog.show(activity)
|
||||
AdLogger.d("当前没有广告,立即加载Pangle插页广告,广告位ID: %s", finalAdUnitId)
|
||||
val loadResult = loadAd(activity, finalAdUnitId)
|
||||
if (loadResult is AdResult.Failure) {
|
||||
ADLoadingDialog.hide()
|
||||
return loadResult
|
||||
}
|
||||
}
|
||||
|
||||
val ad = currentInterstitialAd
|
||||
if (ad != null) {
|
||||
ADLoadingDialog.hide()
|
||||
AdLogger.d("显示Pangle插页广告,广告位ID: %s", finalAdUnitId)
|
||||
|
||||
// 2. 显示广告
|
||||
val result = showAdInternal(activity, ad, finalAdUnitId)
|
||||
|
||||
// 清空当前广告,Pangle SDK会自动加载下一个
|
||||
currentInterstitialAd = null
|
||||
|
||||
result
|
||||
} else {
|
||||
ADLoadingDialog.hide()
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示Pangle插页广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
} finally {
|
||||
ADLoadingDialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
@Suppress("RedundantNullableInit")
|
||||
private suspend fun showAdInternal(
|
||||
activity: Activity,
|
||||
interstitialAd: PAGInterstitialAd,
|
||||
adUnitId: String
|
||||
): AdResult<Unit> {
|
||||
val applicationContext = activity.applicationContext
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
// 临时变量保存收益数据
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
interstitialAd.setAdInteractionListener(object : PAGInterstitialAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle插页广告开始显示")
|
||||
val pagRevenueInfo: PAGRevenueInfo? = interstitialAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
val currentPlacement = ecpmInfo?.placement
|
||||
val currentRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
val impressionValue = revenueUsd
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("Pangle插页广告累积展示次数: $totalShowCount")
|
||||
|
||||
AdConfigManager.getInterstitialConfig().recordShow()
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = adUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = currentPlacement,
|
||||
ecpmAdUnitId = currentRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsdLong = revenueValue.toLong()
|
||||
AdLogger.d(
|
||||
"Pangle插页广告收益上报(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
currentRevenueAdUnit ?: adUnitId,
|
||||
currentPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle插页广告被点击")
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("Pangle插页广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getInterstitialConfig().recordClick()
|
||||
|
||||
val pagRevenueInfo: PAGRevenueInfo? = interstitialAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
val revenueValue = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to revenueValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle插页广告关闭")
|
||||
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
|
||||
totalCloseCount++
|
||||
|
||||
val pagRevenueInfo: PAGRevenueInfo? = interstitialAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
val revenueValue = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to revenueValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
// 插页关闭时重新预缓存
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle插页广告关闭,开始重新预缓存,广告位ID: %s", adUnitId)
|
||||
preloadAd(applicationContext, adUnitId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle插页广告重新预缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
val result = AdResult.Success(Unit)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(error: PAGErrorModel) {
|
||||
super.onAdShowFailed(error)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle插页广告显示失败: code=%d, message=%s",
|
||||
error.errorCode,
|
||||
error.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to error.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (!interstitialAd.isReady) {
|
||||
AdLogger.w("Pangle插页广告未就绪,无法显示")
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "interstitial_not_ready",
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
val result = AdResult.Failure(createAdException("广告未准备就绪"))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
// 显示广告(必须在主线程调用)
|
||||
try {
|
||||
interstitialAd.show(activity)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示Pangle插页广告异常", e)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
val result = AdResult.Failure(createAdException("显示失败: ${e.message}", e))
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAd(): PAGInterstitialAd? = currentInterstitialAd
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
currentInterstitialAd = null
|
||||
currentInterstitialRequest = null
|
||||
AdLogger.d("Pangle插页广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("Pangle插页广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插页广告是否正在显示的状态
|
||||
* @return true 如果插页广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "Interstitial"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "Interstitial"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle插页广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.content.Context
|
||||
import com.bytedance.sdk.openadsdk.api.PAGMInitSuccessModel
|
||||
import com.bytedance.sdk.openadsdk.api.init.PAGConfig
|
||||
import com.bytedance.sdk.openadsdk.api.init.PAGMConfig
|
||||
import com.bytedance.sdk.openadsdk.api.init.PAGMSdk
|
||||
import com.bytedance.sdk.openadsdk.api.init.PAGSdk
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Pangle SDK 管理器
|
||||
* 负责SDK初始化和全局配置
|
||||
* 参考文档: https://www.pangleglobal.com/integration/android-initialize-pangle-sdk
|
||||
*/
|
||||
object PangleManager {
|
||||
|
||||
private const val TAG = "PangleManager"
|
||||
|
||||
private val _initializationState = MutableStateFlow<AdResult<Unit>>(AdResult.Loading)
|
||||
val initializationState: StateFlow<AdResult<Unit>> = _initializationState.asStateFlow()
|
||||
|
||||
private var isInitialized = false
|
||||
|
||||
/**
|
||||
* 初始化 Pangle SDK
|
||||
* @param context 上下文
|
||||
* @param appId Pangle App ID
|
||||
* @param appIconId 应用图标资源ID(用于App Open Ads)
|
||||
*/
|
||||
suspend fun initialize(context: Context, appId: String, appIconId: Int? = null): AdResult<Unit> {
|
||||
if (isInitialized || PAGSdk.isInitSuccess()) {
|
||||
isInitialized = true
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
_initializationState.value = AdResult.Loading
|
||||
|
||||
try {
|
||||
val configBuilder = PAGMConfig.Builder()
|
||||
.appId(appId)
|
||||
.debugLog(AdLogger.isLogEnabled()) // 测试阶段打开,可以通过日志排查问题,上线时关闭该开关
|
||||
.supportMultiProcess(false) // 是否支持多进程
|
||||
|
||||
// 如果提供了应用图标,设置它(App Open Ads需要)
|
||||
appIconId?.let {
|
||||
configBuilder.appIcon(it)
|
||||
}
|
||||
|
||||
val config = configBuilder.build()
|
||||
|
||||
PAGMSdk.init(context, config, object : PAGMSdk.PAGMInitCallback {
|
||||
override fun success(pagmInitSuccessModel:PAGMInitSuccessModel) {
|
||||
AdLogger.d("Pangle SDK初始化完成")
|
||||
isInitialized = true
|
||||
val result = AdResult.Success(Unit)
|
||||
_initializationState.value = result
|
||||
continuation.resume(result)
|
||||
}
|
||||
|
||||
override fun fail(pagErrorModel:PAGErrorModel) {
|
||||
val code = pagErrorModel.errorCode
|
||||
val msg = pagErrorModel.errorMessage
|
||||
AdLogger.e("Pangle SDK初始化失败,错误码: %d, 错误信息: %s", code, msg ?: "")
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "SDK初始化失败: $msg (code: $code)"
|
||||
)
|
||||
)
|
||||
_initializationState.value = result
|
||||
continuation.resume(result)
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle SDK初始化过程中发生异常", e)
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "SDK初始化异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
_initializationState.value = result
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查SDK是否已初始化
|
||||
*/
|
||||
fun isInitialized(): Boolean {
|
||||
return isInitialized || PAGSdk.isInitSuccess()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前初始化状态
|
||||
*/
|
||||
fun getCurrentInitializationState(): AdResult<Unit> {
|
||||
return _initializationState.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有广告控制器的快捷访问器
|
||||
*/
|
||||
object Controllers {
|
||||
val appOpen: PangleAppOpenAdController
|
||||
get() = PangleAppOpenAdController.getInstance()
|
||||
|
||||
val interstitial: PangleInterstitialAdController
|
||||
get() = PangleInterstitialAdController.getInstance()
|
||||
|
||||
val banner: PangleBannerAdController
|
||||
get() = PangleBannerAdController.getInstance()
|
||||
|
||||
val native: PangleNativeAdController
|
||||
get() = PangleNativeAdController.getInstance()
|
||||
|
||||
val fullScreenNative: PangleFullScreenNativeAdController
|
||||
get() = PangleFullScreenNativeAdController.getInstance()
|
||||
|
||||
val rewarded: PangleRewardedAdController
|
||||
get() = PangleRewardedAdController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有控制器资源
|
||||
*/
|
||||
fun destroyAll() {
|
||||
Controllers.appOpen.destroy()
|
||||
Controllers.interstitial.destroy()
|
||||
Controllers.banner.destroy()
|
||||
Controllers.native.destroy()
|
||||
Controllers.fullScreenNative.destroy()
|
||||
Controllers.rewarded.destroy()
|
||||
AdLogger.d("所有Pangle广告控制器已清理")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,625 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAd
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeRequest
|
||||
import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.pangle.PangleNativeAdStyle
|
||||
import com.remax.bill.ui.pangle.PangleNativeAdView
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Pangle原生广告控制器
|
||||
* 提供原生广告的加载和管理功能
|
||||
* 参考文档: https://www.pangleglobal.com/integration/android-native-ads
|
||||
*
|
||||
* 注意:Pangle原生广告支持四种格式:
|
||||
* - 大图(1.91:1比例)
|
||||
* - 1280*720视频
|
||||
* - 方形图片
|
||||
* - 方形视频
|
||||
*/
|
||||
class PangleNativeAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("pangle_native_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_native_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_native_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_native_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_native_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_native_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("pangle_native_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleNativeAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L // 1小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleNativeAdController? = null
|
||||
|
||||
fun getInstance(): PangleNativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleNativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedNativeAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val nativeAdView = PangleNativeAdView()
|
||||
|
||||
/**
|
||||
* 缓存的原生广告数据类
|
||||
*/
|
||||
private data class CachedNativeAd(
|
||||
val ad: PAGNativeAd,
|
||||
val adUnitId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载原生广告(可选,用于提前准备)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "原生广告全局开关已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_NATIVE_ID
|
||||
return loadAdToCache(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原生广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun getAd(context: Context, adUnitId: String? = null): AdResult<PAGNativeAd> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_NATIVE_ID
|
||||
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalAdUnitId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载Pangle原生广告,广告位ID: %s", finalAdUnitId)
|
||||
loadAdToCache(context, finalAdUnitId)
|
||||
cachedAd = getCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
return if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的Pangle原生广告,广告位ID: %s", finalAdUnitId)
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("load ad fail"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示原生广告到指定容器(简化版接口)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器(根视图)
|
||||
* @param style 广告样式,默认为标准样式
|
||||
* @param adUnitId 广告位ID,如果为空则使用默认ID
|
||||
* @return 是否显示成功
|
||||
*/
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
style: PangleNativeAdStyle = PangleNativeAdStyle.STANDARD,
|
||||
adUnitId: String? = null
|
||||
): Boolean {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_NATIVE_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Pangle原生广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.w("Pangle原生广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
return false
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
when (val result = getAd(context, adUnitId)) {
|
||||
is AdResult.Success -> {
|
||||
val nativeAd = result.data
|
||||
|
||||
if(!nativeAd.isReady){
|
||||
throw IllegalArgumentException("native_not_ready")
|
||||
}
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
var impressionPlacement: String? = null
|
||||
var impressionRevenueAdUnit: String? = null
|
||||
|
||||
// 使用PangleNativeAdView绑定广告到容器,内部会处理 registerViewForInteraction
|
||||
val success = nativeAdView.bindNativeAdToContainer(
|
||||
context = context,
|
||||
container = container,
|
||||
nativeAd = nativeAd,
|
||||
style = style,
|
||||
interactionListener = object : PAGNativeAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle原生广告开始显示")
|
||||
val pagRevenueInfo: PAGRevenueInfo? = nativeAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
impressionPlacement = ecpmInfo?.placement
|
||||
impressionRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
val impressionValue = revenueUsd
|
||||
|
||||
totalShowCount++
|
||||
AdLogger.d("Pangle原生广告累积展示次数: $totalShowCount")
|
||||
AdConfigManager.getNativeConfig().recordShow()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = finalAdUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = impressionPlacement,
|
||||
ecpmAdUnitId = impressionRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull()?.toLong() ?: 0L
|
||||
AdLogger.d(
|
||||
"Pangle原生广告收益上报(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
impressionRevenueAdUnit ?: finalAdUnitId,
|
||||
impressionPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle原生广告被点击")
|
||||
totalClickCount++
|
||||
AdLogger.d("Pangle原生广告累积点击次数: $totalClickCount")
|
||||
AdConfigManager.getNativeConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (nativeAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle原生广告关闭")
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (nativeAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(error: PAGErrorModel) {
|
||||
super.onAdShowFailed(error)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle原生广告显示失败: code=%d, message=%s",
|
||||
error.errorCode,
|
||||
error.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to error.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (success) {
|
||||
true
|
||||
} else {
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle原生广告累积展示失败次数: $totalShowFailCount")
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "bind_failed"
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
AdResult.Loading -> {
|
||||
// 保持加载状态
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("Pangle原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "${e.message}"
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.e("显示Pangle原生广告失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法
|
||||
*/
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private suspend fun loadAd(context: Context, adUnitId: String): PAGNativeAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("Pangle原生广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// 创建PAGNativeRequest对象(推荐作为Activity的成员变量)
|
||||
val request = PAGNativeRequest(context)
|
||||
|
||||
// 加载广告并注册回调
|
||||
PAGNativeAd.loadAd(adUnitId, request, object : PAGNativeAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGNativeAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle原生广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(ad)
|
||||
}
|
||||
|
||||
override fun onError(model :PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s",
|
||||
adUnitId, loadTime, code, message)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
private suspend fun loadAdToCache(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentAdUnitCount = getCachedAdCount(adUnitId)
|
||||
if (currentAdUnitCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", adUnitId, currentAdUnitCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val nativeAd = loadAd(context, adUnitId)
|
||||
if (nativeAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedNativeAd(nativeAd, adUnitId))
|
||||
val currentCount = getCachedAdCount(adUnitId)
|
||||
AdLogger.d("Pangle原生广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", adUnitId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle原生loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(adUnitId: String): CachedNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun peekCachedAd(adUnitId: String): PAGNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.firstOrNull { it.adUnitId == adUnitId && !it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(adUnitId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.adUnitId == adUnitId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(adUnitId: String): Boolean {
|
||||
return getCachedAdCount(adUnitId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的广告数据
|
||||
*/
|
||||
fun getCurrentAd(adUnitId: String? = null): PAGNativeAd? {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_NATIVE_ID
|
||||
return peekCachedAd(finalAdUnitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(adUnitId: String? = null): Boolean {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_NATIVE_ID
|
||||
return getCachedAdCount(finalAdUnitId) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
// PAGNativeAd没有destroy方法,只需要清理缓存
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("Pangle原生广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("Pangle原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "Native"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "Native"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle原生广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
package com.remax.bill.ads.pangle
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGAdEcpmInfo
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGErrorModel
|
||||
import com.bytedance.sdk.openadsdk.api.model.PAGRevenueInfo
|
||||
import com.bytedance.sdk.openadsdk.api.reward.PAGRewardItem
|
||||
import com.bytedance.sdk.openadsdk.api.reward.PAGRewardedAd
|
||||
import com.bytedance.sdk.openadsdk.api.reward.PAGRewardedAdInteractionCallback
|
||||
import com.bytedance.sdk.openadsdk.api.reward.PAGRewardedAdLoadCallback
|
||||
import com.bytedance.sdk.openadsdk.api.reward.PAGRewardedRequest
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Pangle激励视频广告控制器
|
||||
* 参考文档:https://www.pangleglobal.com/integration/android-rewarded-video-ads
|
||||
*/
|
||||
class PangleRewardedAdController private constructor() {
|
||||
|
||||
private var totalClickCount by KvIntDelegate("pangle_rewarded_ad_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("pangle_rewarded_ad_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("pangle_rewarded_ad_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("pangle_rewarded_ad_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("pangle_rewarded_ad_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("pangle_rewarded_ad_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("pangle_rewarded_ad_total_shows", 0)
|
||||
private var totalRewardEarnedCount by KvIntDelegate("pangle_rewarded_ad_total_reward_earned", 0)
|
||||
|
||||
private var currentRewardedAd: PAGRewardedAd? = null
|
||||
private var currentAdUnitId: String? = null
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PangleRewardedAd"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PangleRewardedAdController? = null
|
||||
|
||||
fun getInstance(): PangleRewardedAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PangleRewardedAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "激励广告全局开关已关闭,中断加载"
|
||||
)
|
||||
)
|
||||
}
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_REWARDED_ID
|
||||
|
||||
// 检查当前缓存是否存在且广告已就绪
|
||||
val cachedAd = getCurrentAd(finalAdUnitId)
|
||||
if (cachedAd != null && cachedAd.isReady) {
|
||||
AdLogger.d("Pangle激励广告已有有效缓存且已就绪,广告位ID: %s,跳过加载", finalAdUnitId)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return loadAd(context, finalAdUnitId)
|
||||
}
|
||||
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
adUnitId: String? = null,
|
||||
onRewardEarned: ((PAGRewardItem) -> Unit)? = null
|
||||
): AdResult<Unit> {
|
||||
val finalAdUnitId = adUnitId ?: BuildConfig.PANGLE_REWARDED_ID
|
||||
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("Pangle激励广告累积触发展示次数: $totalShowTriggerCount")
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getRewardedConfig())) {
|
||||
// is AdResult.Failure -> {
|
||||
// totalShowFailCount++
|
||||
// reportAdData(
|
||||
// eventName = "ad_show_fail",
|
||||
// params = mapOf(
|
||||
// "ad_unit_name" to finalAdUnitId,
|
||||
// "position" to PositionGet.get(),
|
||||
// "number" to totalShowFailCount,
|
||||
// "reason" to interceptResult.error.message
|
||||
// )
|
||||
// )
|
||||
// return interceptResult
|
||||
// }
|
||||
// else -> Unit
|
||||
// }
|
||||
|
||||
return try {
|
||||
if (currentRewardedAd == null || currentAdUnitId != finalAdUnitId) {
|
||||
ADLoadingDialog.show(activity)
|
||||
val loadResult = loadAd(activity, finalAdUnitId)
|
||||
if (loadResult is AdResult.Failure) {
|
||||
ADLoadingDialog.hide()
|
||||
return loadResult
|
||||
}
|
||||
}
|
||||
|
||||
val ad = currentRewardedAd
|
||||
if (ad != null) {
|
||||
ADLoadingDialog.hide()
|
||||
currentRewardedAd = null
|
||||
currentAdUnitId = null
|
||||
showAdInternal(activity, ad, finalAdUnitId, onRewardEarned)
|
||||
} else {
|
||||
ADLoadingDialog.hide()
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "load_failed"
|
||||
)
|
||||
)
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ADLoadingDialog.hide()
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalAdUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdLogger.e("显示Pangle激励广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadAd(context: Context, adUnitId: String): AdResult<Unit> {
|
||||
if (adUnitId.isBlank()) {
|
||||
return AdResult.Failure(createAdException("广告位ID为空"))
|
||||
}
|
||||
|
||||
totalLoadCount++
|
||||
AdLogger.d("Pangle激励广告开始加载,广告位ID: $adUnitId")
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
val request = PAGRewardedRequest(context)
|
||||
|
||||
PAGRewardedAd.loadAd(adUnitId, request, object : PAGRewardedAdLoadCallback {
|
||||
override fun onAdLoaded(ad: PAGRewardedAd) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("Pangle激励广告加载成功,广告位ID: %s, 耗时: %dms", adUnitId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
currentRewardedAd = ad
|
||||
currentAdUnitId = adUnitId
|
||||
continuation.resume(AdResult.Success(Unit))
|
||||
}
|
||||
|
||||
override fun onError(model: PAGErrorModel) {
|
||||
val code = model.errorCode
|
||||
val message = model.errorMessage
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("Pangle激励广告加载失败,广告位ID: %s, 耗时: %dms, 错误码: %d, 错误信息: %s", adUnitId, loadTime, code, message)
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "Pangle",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to message
|
||||
)
|
||||
)
|
||||
continuation.resume(AdResult.Failure(createAdException("加载失败: $message")))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAd(adUnitId: String? = null): PAGRewardedAd? {
|
||||
val targetUnitId = adUnitId ?: currentAdUnitId
|
||||
return if (currentAdUnitId == targetUnitId) currentRewardedAd else null
|
||||
}
|
||||
|
||||
private suspend fun showAdInternal(
|
||||
activity: Activity,
|
||||
rewardedAd: PAGRewardedAd,
|
||||
adUnitId: String,
|
||||
onRewardEarned: ((PAGRewardItem) -> Unit)?
|
||||
): AdResult<Unit> {
|
||||
val applicationContext = activity.applicationContext
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
var hasRewarded = false
|
||||
var currentRevenueUsd: Double? = null
|
||||
var currentCurrency: String? = null
|
||||
var currentAdSource: String? = null
|
||||
var currentPlacement: String? = null
|
||||
var currentRevenueAdUnit: String? = null
|
||||
|
||||
rewardedAd.setAdInteractionCallback(object : PAGRewardedAdInteractionCallback() {
|
||||
override fun onAdShowed() {
|
||||
AdLogger.d("Pangle激励广告开始显示")
|
||||
isShowing = true
|
||||
val pagRevenueInfo: PAGRevenueInfo? = rewardedAd.pagRevenueInfo
|
||||
val ecpmInfo: PAGAdEcpmInfo? = pagRevenueInfo?.showEcpm
|
||||
currentCurrency = ecpmInfo?.currency
|
||||
currentAdSource = ecpmInfo?.adnName
|
||||
currentPlacement = ecpmInfo?.placement
|
||||
currentRevenueAdUnit = ecpmInfo?.adUnit
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsd = ecpmInfo?.revenue?.toDoubleOrNull() ?: 0.0
|
||||
currentRevenueUsd = revenueUsd
|
||||
val impressionValue = revenueUsd
|
||||
totalShowCount++
|
||||
AdConfigManager.getRewardedConfig()?.recordShow()
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to impressionValue,
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
currentRevenueUsd?.let { revenueValue ->
|
||||
reportAdRevenueWithValue(
|
||||
adUnitId = adUnitId,
|
||||
valueUsd = revenueValue,
|
||||
currencyCode = currentCurrency,
|
||||
adNetwork = currentAdSource,
|
||||
placement = currentPlacement,
|
||||
ecpmAdUnitId = currentRevenueAdUnit
|
||||
)
|
||||
// Pangle 的 revenue 本身就是美元,直接使用
|
||||
val revenueUsdLong = revenueValue.toLong()
|
||||
AdLogger.d(
|
||||
"Pangle激励广告收益上报(onShow): adUnit=%s, placement=%s, adn=%s, revenueUsd=%.4f, currency=%s",
|
||||
currentRevenueAdUnit ?: adUnitId,
|
||||
currentPlacement ?: "",
|
||||
currentAdSource ?: "Pangle",
|
||||
revenueValue,
|
||||
currentCurrency ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked() {
|
||||
AdLogger.d("Pangle激励广告被点击")
|
||||
totalClickCount++
|
||||
AdConfigManager.getRewardedConfig()?.recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle"),
|
||||
"value" to (rewardedAd.pagRevenueInfo?.showEcpm?.revenue?.toDoubleOrNull() ?: 0.0),
|
||||
"currency" to (currentCurrency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdDismissed() {
|
||||
AdLogger.d("Pangle激励广告关闭")
|
||||
isShowing = false
|
||||
closeEvent(
|
||||
adUnitId = adUnitId,
|
||||
adSource = currentAdSource,
|
||||
valueUsd = currentRevenueUsd,
|
||||
currencyCode = currentCurrency,
|
||||
isEnded = if (hasRewarded) "true" else ""
|
||||
)
|
||||
|
||||
// 激励关闭时重新预缓存
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("Pangle激励广告关闭,开始重新预缓存,广告位ID: %s", adUnitId)
|
||||
preloadAd(applicationContext, adUnitId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle激励广告重新预缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AdResult.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserEarnedReward(rewardItem: PAGRewardItem) {
|
||||
AdLogger.d("Pangle激励广告发放奖励: name=${rewardItem.rewardName}, amount=${rewardItem.rewardAmount}")
|
||||
totalRewardEarnedCount++
|
||||
hasRewarded = true
|
||||
reportAdData(
|
||||
eventName = "ad_reward_earned",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalRewardEarnedCount,
|
||||
"reward_name" to rewardItem.rewardName,
|
||||
"reward_amount" to rewardItem.rewardAmount,
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
onRewardEarned?.invoke(rewardItem)
|
||||
}
|
||||
|
||||
override fun onUserEarnedRewardFail(model: PAGErrorModel) {
|
||||
AdLogger.w("Pangle激励广告奖励下发失败,错误码: ${model.errorCode}")
|
||||
}
|
||||
|
||||
override fun onAdShowFailed(model: PAGErrorModel) {
|
||||
super.onAdShowFailed(model)
|
||||
totalShowFailCount++
|
||||
AdLogger.e(
|
||||
"Pangle激励广告显示失败: code=%d, message=%s",
|
||||
model.errorCode,
|
||||
model.errorMessage
|
||||
)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to model.errorMessage.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
if (!rewardedAd.isReady) {
|
||||
AdLogger.w("Pangle激励广告未就绪,无法显示")
|
||||
isShowing = false
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "rewarded_not_ready",
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AdResult.Failure(createAdException("广告未准备就绪")))
|
||||
}
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
rewardedAd.show(activity)
|
||||
} catch (e: Exception) {
|
||||
isShowing = false
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty(),
|
||||
"ad_source" to (currentAdSource ?: "Pangle")
|
||||
)
|
||||
)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AdResult.Failure(createAdException("显示异常: ${e.message}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasCachedAd(): Boolean {
|
||||
return currentRewardedAd != null
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
currentRewardedAd = null
|
||||
currentAdUnitId = null
|
||||
AdLogger.d("Pangle激励广告控制器已清理")
|
||||
}
|
||||
|
||||
private fun closeEvent(
|
||||
adUnitId: String,
|
||||
adSource: String? = "Pangle",
|
||||
valueUsd: Double? = null,
|
||||
currencyCode: String? = null,
|
||||
isEnded: String = ""
|
||||
) {
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to adUnitId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adSource ?: "Pangle"),
|
||||
"value" to (valueUsd ?: 0.0),
|
||||
"currency" to (currencyCode ?: "USD"),
|
||||
"isended" to isEnded
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "Pangle",
|
||||
"ad_format" to "Rewarded"
|
||||
)
|
||||
data.putAll(params)
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
private fun reportAdRevenueWithValue(
|
||||
adUnitId: String,
|
||||
valueUsd: Double,
|
||||
currencyCode: String?,
|
||||
adNetwork: String?,
|
||||
placement: String?,
|
||||
ecpmAdUnitId: String?
|
||||
) {
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = valueUsd,
|
||||
currencyCode = currencyCode ?: ""
|
||||
),
|
||||
adRevenueNetwork = adNetwork ?: "Pangle",
|
||||
adRevenueUnit = ecpmAdUnitId ?: adUnitId,
|
||||
adRevenuePlacement = placement ?: "",
|
||||
adFormat = "Rewarded"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"Pangle激励广告真实收益数据已上报,广告位ID: %s, 收益: %.4f %s, adn=%s, placement=%s",
|
||||
ecpmAdUnitId ?: adUnitId,
|
||||
valueUsd,
|
||||
currencyCode ?: "",
|
||||
adNetwork ?: "Pangle",
|
||||
placement ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.remax.bill.ads.provider
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import com.remax.bill.ads.AdActivityInterceptor
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
|
||||
/**
|
||||
* 广告模块内容提供者
|
||||
* 用于在模块初始化时获取 Context 并初始化 AdConfigManager
|
||||
*/
|
||||
class AdModuleProvider : ContentProvider() {
|
||||
|
||||
companion object {
|
||||
private var applicationContext: android.content.Context? = null
|
||||
|
||||
/**
|
||||
* 获取应用上下文
|
||||
*/
|
||||
fun getApplicationContext(): android.content.Context? = applicationContext
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
applicationContext = context?.applicationContext
|
||||
applicationContext?.let { ctx ->
|
||||
try {
|
||||
AdConfigManager.initialize(ctx)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdConfigManager 初始化失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? = null
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): Int = 0
|
||||
}
|
||||
|
|
@ -0,0 +1,579 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.topon.ToponBannerAdView
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdConst
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.banner.api.TUBannerView
|
||||
import com.thinkup.banner.api.TUBannerListener
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn Banner 广告控制器
|
||||
* 参考 AdMob Banner 广告控制器实现,保持埋点一致
|
||||
* 参考文档: https://help.toponad.net/cn/docs/heng-fu-guang-gao
|
||||
*/
|
||||
class TopOnBannerAdController private constructor() {
|
||||
|
||||
// 累积统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_banner_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("topon_banner_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("topon_banner_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_banner_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_banner_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_banner_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("topon_banner_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TopOnBannerAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L // 1小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
private const val BANNER_WIDTH_320 = 320 // 标准 Banner 宽度(dp)
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnBannerAdController? = null
|
||||
|
||||
fun getInstance(): TopOnBannerAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnBannerAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedBannerAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val bannerView = ToponBannerAdView()
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdInfo: TUAdInfo? = null
|
||||
|
||||
/**
|
||||
* 缓存的 Banner 广告数据类
|
||||
*/
|
||||
private data class CachedBannerAd(
|
||||
val bannerView: TUBannerView,
|
||||
val placementId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Banner 广告视图
|
||||
* @param activity Activity 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
fun createBannerAdView(activity: Activity, placementId: String? = null): TUBannerView {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_BANNER_ID
|
||||
return TUBannerView(activity).apply {
|
||||
setPlacementId(finalPlacementId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载 Banner 广告
|
||||
* @param activity Activity 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(activity: Activity, placementId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "Banner 广告全局开关已关闭"
|
||||
)
|
||||
)
|
||||
}
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_BANNER_ID
|
||||
return loadAdToCache(activity, finalPlacementId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示 Banner 广告(自动处理加载)
|
||||
* @param activity Activity 上下文
|
||||
* @param container 目标容器
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
container: ViewGroup,
|
||||
placementId: String? = null
|
||||
): AdResult<TUBannerView> {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_BANNER_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("TopOn Banner 广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getBannerConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalPlacementId)
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载 TopOn Banner 广告,广告位ID: %s", finalPlacementId)
|
||||
loadAdToCache(activity, finalPlacementId)
|
||||
cachedAd = getCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的 TopOn Banner 广告,广告位ID: %s", finalPlacementId)
|
||||
|
||||
// 绑定广告到容器
|
||||
val success = bannerView.bindBannerAdToContainer(
|
||||
activity, container, cachedAd.bannerView, null
|
||||
)
|
||||
|
||||
if (success) {
|
||||
AdConfigManager.getBannerConfig().recordShow()
|
||||
if (!isCacheFull(finalPlacementId)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
preloadAd(activity, finalPlacementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn Banner 广告预加载失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
AdResult.Success(cachedAd.bannerView)
|
||||
} else {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "广告绑定失败"
|
||||
)
|
||||
)
|
||||
AdResult.Failure(createAdException("广告绑定失败"))
|
||||
}
|
||||
} else {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "No fill"
|
||||
)
|
||||
)
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdLogger.e("显示 TopOn Banner 广告失败", e)
|
||||
container.removeAllViews()
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -1,
|
||||
message = "显示 Banner 广告异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(placementId: String): CachedBannerAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.placementId == placementId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(placementId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.placementId == placementId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位的缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(placementId: String): Boolean {
|
||||
return getCachedAdCount(placementId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(activity: Activity, placementId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentPlacementCount = getCachedAdCount(placementId)
|
||||
if (currentPlacementCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", placementId, currentPlacementCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val bannerView = loadAd(activity, placementId)
|
||||
if (bannerView != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedBannerAd(bannerView, placementId))
|
||||
val currentCount = getCachedAdCount(placementId)
|
||||
AdLogger.d("TopOn Banner 广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", placementId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn Banner loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法
|
||||
*/
|
||||
private suspend fun loadAd(activity: Activity, placementId: String): TUBannerView? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn Banner 广告开始加载,广告位ID: %s,当前累计加载次数: %d", placementId, totalLoadCount)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
val bannerView = TUBannerView(activity)
|
||||
bannerView.setPlacementId(placementId)
|
||||
|
||||
val displayMetrics = activity.resources.displayMetrics
|
||||
val adWidth = displayMetrics.widthPixels
|
||||
val adHeight = (60 * displayMetrics.density).toInt()
|
||||
|
||||
bannerView.layoutParams = ViewGroup.LayoutParams(adWidth, adHeight)
|
||||
|
||||
// 设置监听器
|
||||
bannerView.setBannerAdListener(object : TUBannerListener {
|
||||
override fun onBannerLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
totalLoadSucCount++
|
||||
|
||||
AdLogger.d("TopOn Banner 广告加载成功,广告位ID: %s, 耗时: %dms", placementId, loadTime)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(bannerView)
|
||||
}
|
||||
|
||||
override fun onBannerFailed(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("TopOn Banner 广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", placementId, loadTime, adError.getFullErrorInfo())
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onBannerClicked(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn Banner 广告被点击")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("TopOn Banner 广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getBannerConfig().recordClick()
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBannerShow(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn Banner 广告展示完成")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("TopOn Banner 广告累积展示次数: $totalShowCount")
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
|
||||
// TopOn 的 revenueValue 已经是美元,不需要转换
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
|
||||
reportAdRevenueWithValue(placementId, adInfo)
|
||||
}
|
||||
|
||||
override fun onBannerClose(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn Banner 广告关闭")
|
||||
currentAdInfo = adInfo
|
||||
totalCloseCount++
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBannerAutoRefreshed(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn Banner 广告自动刷新")
|
||||
currentAdInfo = adInfo
|
||||
}
|
||||
|
||||
override fun onBannerAutoRefreshFail(adError: AdError) {
|
||||
AdLogger.e("TopOn Banner 广告自动刷新失败: %s", adError.getFullErrorInfo())
|
||||
}
|
||||
})
|
||||
|
||||
val localExtra = mutableMapOf<String, Any>()
|
||||
localExtra[TUAdConst.KEY.AD_WIDTH] = adWidth
|
||||
localExtra[TUAdConst.KEY.AD_HEIGHT] = adHeight
|
||||
bannerView.setLocalExtra(localExtra)
|
||||
|
||||
// 加载广告
|
||||
bannerView.loadAd()
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn Banner 广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekCachedAd(placementId: String = BuildConfig.TOPON_BANNER_ID): TUBannerView? {
|
||||
return synchronized(adCachePool) {
|
||||
adCachePool.firstOrNull { it.placementId == placementId && !it.isExpired() }?.bannerView
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAd(placementId: String? = null): TUBannerView? {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_BANNER_ID
|
||||
return peekCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
fun hasCachedAd(placementId: String? = null): Boolean {
|
||||
synchronized(adCachePool) {
|
||||
return if (placementId != null) {
|
||||
adCachePool.any { it.placementId == placementId && !it.isExpired() }
|
||||
} else {
|
||||
adCachePool.any { !it.isExpired() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param adInfo 广告信息
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(placementId: String, adInfo: TUAdInfo) {
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = revenueValue,
|
||||
currencyCode = revenueCurrency
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = placementId,
|
||||
adRevenuePlacement = adInfo.placementId ?: "",
|
||||
adFormat = "Banner"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("TopOn Banner 广告真实收益数据已上报,广告位ID: %s, 收益: %.8f %s", placementId, revenueValue, revenueCurrency)
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { cachedAd -> cachedAd.bannerView.destroy() }
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("TopOn Banner 广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn Banner 广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "Banner"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,655 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.topon.ToponFullScreenNativeAdView
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdConst
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.nativead.api.NativeAd
|
||||
import com.thinkup.nativead.api.TUNative
|
||||
import com.thinkup.nativead.api.TUNativeNetworkListener
|
||||
import com.thinkup.nativead.api.TUNativeEventListener
|
||||
import com.thinkup.nativead.api.TUNativeDislikeListener
|
||||
import com.thinkup.nativead.api.TUNativeAdView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn 全屏原生广告控制器
|
||||
* 参考 AdMob 全屏原生广告控制器实现,保持埋点一致
|
||||
*/
|
||||
class TopOnFullScreenNativeAdController private constructor() {
|
||||
|
||||
// 累积统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_full_native_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("topon_full_native_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("topon_full_native_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_full_native_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_full_native_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_full_native_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("topon_full_native_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TopOnFullScreenNativeAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L // 1小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnFullScreenNativeAdController? = null
|
||||
|
||||
fun getInstance(): TopOnFullScreenNativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnFullScreenNativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedFullScreenNativeAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val fullScreenAdView = ToponFullScreenNativeAdView()
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdInfo: TUAdInfo? = null
|
||||
|
||||
// 全屏原生广告是否正在显示的标识
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
/**
|
||||
* 缓存的全屏原生广告数据类
|
||||
*/
|
||||
private data class CachedFullScreenNativeAd(
|
||||
val ad: TUNative,
|
||||
val placementId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载全屏原生广告
|
||||
* @param context 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, placementId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全屏原生广告全局开关已关闭"
|
||||
)
|
||||
)
|
||||
}
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_FULL_NATIVE_ID
|
||||
return loadAdToCache(context, finalPlacementId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示全屏原生广告到指定容器
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param lifecycleOwner 生命周期所有者
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
* @return AdResult<Unit> 广告显示结果
|
||||
*/
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
placementId: String? = null
|
||||
): AdResult<Unit> {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_FULL_NATIVE_ID
|
||||
|
||||
totalShowTriggerCount++
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getFullscreenNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
return AdResult.Failure(interceptResult.error)
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
// 显示加载视图
|
||||
fullScreenAdView.createFullScreenLoadingView(context, container)
|
||||
|
||||
when (val result = getAd(context, finalPlacementId)) {
|
||||
is AdResult.Success -> {
|
||||
val tuNative = result.data
|
||||
val nativeAd = tuNative.getNativeAd()
|
||||
if (nativeAd == null) {
|
||||
AdLogger.e("TopOn全屏原生广告获取NativeAd失败")
|
||||
return AdResult.Failure(createAdException("广告数据获取失败"))
|
||||
}
|
||||
|
||||
// 设置事件监听器
|
||||
nativeAd.setNativeEventListener(createNativeEventListener(finalPlacementId, nativeAd))
|
||||
|
||||
// 设置关闭按钮监听器
|
||||
nativeAd.setDislikeCallbackListener(object : TUNativeDislikeListener() {
|
||||
override fun onAdCloseButtonClick(
|
||||
p0: TUNativeAdView?,
|
||||
adInfo: TUAdInfo
|
||||
) {
|
||||
AdLogger.d("TopOn全屏原生广告关闭")
|
||||
currentAdInfo = adInfo
|
||||
totalCloseCount++
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 绑定广告到容器
|
||||
val success = fullScreenAdView.bindFullScreenNativeAdToContainer(
|
||||
context, container, nativeAd, lifecycleOwner
|
||||
)
|
||||
|
||||
if (success) {
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "广告绑定失败"
|
||||
)
|
||||
)
|
||||
AdResult.Failure(createAdException("广告绑定失败"))
|
||||
}
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
AdResult.Failure(result.error)
|
||||
}
|
||||
AdResult.Loading -> {
|
||||
AdLogger.w("TopOn全屏原生广告正在加载中")
|
||||
AdResult.Loading
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示TopOn全屏原生广告失败", e)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to e.message.orEmpty()
|
||||
)
|
||||
)
|
||||
AdResult.Failure(AdException(code = -2, message = "显示全屏原生广告异常: ${e.message}", cause = e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun getAd(context: Context, placementId: String? = null): AdResult<TUNative> {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_FULL_NATIVE_ID
|
||||
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalPlacementId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载TopOn全屏原生广告,广告位ID: %s", finalPlacementId)
|
||||
loadAdToCache(context, finalPlacementId)
|
||||
cachedAd = getCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
return if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的TopOn全屏原生广告,广告位ID: %s", finalPlacementId)
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(placementId: String): CachedFullScreenNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.placementId == placementId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(placementId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.placementId == placementId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位的缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(placementId: String): Boolean {
|
||||
return getCachedAdCount(placementId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(context: Context, placementId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentPlacementCount = getCachedAdCount(placementId)
|
||||
if (currentPlacementCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", placementId, currentPlacementCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val tuNative = loadAd(context, placementId)
|
||||
if (tuNative != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedFullScreenNativeAd(tuNative, placementId))
|
||||
val currentCount = getCachedAdCount(placementId)
|
||||
AdLogger.d("TopOn全屏原生广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", placementId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn全屏原生loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, placementId: String): TUNative? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn全屏原生广告开始加载,广告位ID: %s,当前累计加载次数: %d", placementId, totalLoadCount)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
// 将 tuNative 定义在外部作用域,以便在回调中访问
|
||||
var tuNative: TUNative? = null
|
||||
|
||||
try {
|
||||
tuNative = TUNative(applicationContext, placementId, object : TUNativeNetworkListener {
|
||||
override fun onNativeAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
totalLoadSucCount++
|
||||
|
||||
AdLogger.d("TopOn全屏原生广告加载成功,广告位ID: %s, 耗时: %dms", placementId, loadTime)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 直接返回 TUNative
|
||||
continuation.resume(tuNative)
|
||||
}
|
||||
|
||||
override fun onNativeAdLoadFail(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("TopOn全屏原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", placementId, loadTime, adError.getFullErrorInfo())
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
|
||||
// 配置广告宽高(全屏)
|
||||
val displayMetrics = applicationContext.resources.displayMetrics
|
||||
val adViewWidth = displayMetrics.widthPixels
|
||||
val adViewHeight = displayMetrics.heightPixels
|
||||
|
||||
val localExtra = mutableMapOf<String, Any>()
|
||||
localExtra[TUAdConst.KEY.AD_WIDTH] = adViewWidth
|
||||
localExtra[TUAdConst.KEY.AD_HEIGHT] = adViewHeight
|
||||
tuNative.setLocalExtra(localExtra)
|
||||
|
||||
// 发起广告请求
|
||||
tuNative.makeAdRequest()
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn全屏原生广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建原生广告事件监听器
|
||||
*/
|
||||
private fun createNativeEventListener(
|
||||
placementId: String,
|
||||
nativeAd: NativeAd
|
||||
): TUNativeEventListener {
|
||||
return object : TUNativeEventListener {
|
||||
override fun onAdImpressed(view: TUNativeAdView, adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn全屏原生广告展示完成")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 设置广告正在显示标识
|
||||
isShowing = true
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("TopOn全屏原生广告累积展示次数: $totalShowCount")
|
||||
|
||||
// 记录展示
|
||||
AdConfigManager.getFullscreenNativeConfig().recordShow()
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
|
||||
// TopOn 的 revenueValue 已经是美元,不需要转换
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
|
||||
reportAdRevenueWithValue(placementId, adInfo)
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(placementId)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
preloadAd(view.context, placementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn全屏原生广告预加载失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked(view: TUNativeAdView, adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn全屏原生广告被点击")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("TopOn全屏原生广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getFullscreenNativeConfig().recordClick()
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdVideoStart(p0: TUNativeAdView?) {
|
||||
}
|
||||
|
||||
override fun onAdVideoEnd(p0: TUNativeAdView?) {
|
||||
}
|
||||
|
||||
override fun onAdVideoProgress(
|
||||
p0: TUNativeAdView?,
|
||||
p1: Int
|
||||
) {
|
||||
}
|
||||
|
||||
fun onAdClosed(view: TUNativeAdView, adInfo: TUAdInfo) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun closeEvent(placementId: String = "") {
|
||||
// 设置广告不再显示标识
|
||||
isShowing = false
|
||||
totalCloseCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to runCatching { PositionGet.get() }.getOrDefault(""),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (currentAdInfo?.networkName ?: ""),
|
||||
"value" to (currentAdInfo?.publisherRevenue ?: 0.0),
|
||||
"currency" to (currentAdInfo?.currency ?: "USD")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun peekCachedAd(placementId: String = BuildConfig.TOPON_FULL_NATIVE_ID): TUNative? {
|
||||
return synchronized(adCachePool) {
|
||||
adCachePool.firstOrNull { it.placementId == placementId && !it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentAd(placementId: String? = null): TUNative? {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_FULL_NATIVE_ID
|
||||
return peekCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
fun hasCachedAd(placementId: String? = null): Boolean {
|
||||
synchronized(adCachePool) {
|
||||
return if (placementId != null) {
|
||||
adCachePool.any { it.placementId == placementId && !it.isExpired() }
|
||||
} else {
|
||||
adCachePool.any { !it.isExpired() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param adInfo 广告信息
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(placementId: String, adInfo: TUAdInfo) {
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = revenueValue,
|
||||
currencyCode = revenueCurrency
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = placementId,
|
||||
adRevenuePlacement = adInfo.placementId ?: "",
|
||||
adFormat = "FullNative"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("TopOn全屏原生广告真实收益数据已上报,广告位ID: %s, 收益: %.8f %s", placementId, revenueValue, revenueCurrency)
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("TopOn全屏原生广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn全屏原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = -1,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "FullNative"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全屏原生广告是否正在显示的状态
|
||||
* @return true 如果全屏原生广告正在显示,false 否则
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,613 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.FullScreenNativeAdController
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.FullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.ext.AdShowExt
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.core.api.TUAdRevenueListener
|
||||
import com.thinkup.interstitial.api.TUInterstitialListener
|
||||
import com.thinkup.interstitial.api.TUInterstitial
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn 插页广告控制器
|
||||
* 参考 AdMob 插页广告控制器实现,保持埋点一致
|
||||
*/
|
||||
class TopOnInterstitialAdController private constructor() {
|
||||
|
||||
// 累积统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_interstitial_ad_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("topon_interstitial_ad_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("topon_interstitial_ad_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_interstitial_ad_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_interstitial_ad_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_interstitial_ad_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("topon_interstitial_ad_total_shows", 0)
|
||||
|
||||
// 是否正在展示
|
||||
@Volatile
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val CACHE_EXPIRE_MS = 60 * 60 * 1000L
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnInterstitialAdController? = null
|
||||
|
||||
fun getInstance(): TopOnInterstitialAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnInterstitialAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val interceptorChain = InterceptorChain(
|
||||
listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val adCache = mutableMapOf<String, TopOnAdEntry>()
|
||||
|
||||
/**
|
||||
* 缓存的广告实体
|
||||
*/
|
||||
private data class TopOnAdEntry(
|
||||
val placementId: String,
|
||||
val ad: TUInterstitial,
|
||||
val listener: TopOnInterstitialListener,
|
||||
val cacheTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - cacheTime > CACHE_EXPIRE_MS || !ad.isAdReady
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, placementId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全局广告开关关闭,终止加载"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
AdLogger.w("TopOn插页广告缺少有效的广告位ID,无法预加载")
|
||||
return AdResult.Failure(createAdException("广告位ID缺失"))
|
||||
}
|
||||
|
||||
val cached = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
if (cached != null) {
|
||||
AdLogger.d("TopOn插页广告已有有效缓存,广告位ID: %s", finalPlacementId)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return if (loadAd(context, finalPlacementId) != null) {
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示广告
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
placementId: String? = null,
|
||||
ignoreFullNative: Boolean = false
|
||||
): AdResult<Unit> {
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
return AdResult.Failure(createAdException("广告位ID缺失"))
|
||||
}
|
||||
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("TopOn插页广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
when (val interceptResult =
|
||||
interceptorChain.intercept(activity, AdConfigManager.getInterstitialConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
totalShowFailCount++
|
||||
AdLogger.d("TopOn插页广告拦截后累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
return interceptResult
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val interval = AdConfigManager.getFullscreenNativeAfterInterstitialCount()
|
||||
val todayShowInter = AdConfigManager.getInterstitialConfig().getDailyShowCount()
|
||||
val needShowNativeFull =
|
||||
interval > 0 && todayShowInter > 0 && todayShowInter % interval == 0
|
||||
AdLogger.d(
|
||||
"TopOn当前已展示${todayShowInter}个插页,每展示${interval}个插页展示全屏原生,下一个是否展示: $needShowNativeFull"
|
||||
)
|
||||
if (!ignoreFullNative && needShowNativeFull) {
|
||||
return AdShowExt.showFullScreenNativeAdInContainer(activity,true)
|
||||
}
|
||||
|
||||
return try {
|
||||
var entry = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
|
||||
if (entry == null) {
|
||||
ADLoadingDialog.show(activity)
|
||||
loadAd(activity, finalPlacementId)
|
||||
entry = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
if (entry != null && entry.ad.isAdReady) {
|
||||
ADLoadingDialog.hide()
|
||||
AdLogger.d("TopOn使用缓存插页广告展示,广告位ID: %s", finalPlacementId)
|
||||
entry.listener.awaitShow(activity)
|
||||
} else {
|
||||
ADLoadingDialog.hide()
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn插页广告展示异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
} finally {
|
||||
ADLoadingDialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, placementId: String): TopOnAdEntry? {
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn插页广告开始加载,广告位ID: %s,当前累计加载次数: %d", placementId, totalLoadCount)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
try {
|
||||
val applicationContext = context.applicationContext
|
||||
val interstitial = TUInterstitial(applicationContext, placementId)
|
||||
val listener = TopOnInterstitialListener(
|
||||
placementId = placementId,
|
||||
startLoadTime = System.currentTimeMillis(),
|
||||
interstitial = interstitial,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
listener.attachLoadContinuation(continuation)
|
||||
|
||||
interstitial.setAdListener(listener)
|
||||
interstitial.setAdRevenueListener(listener)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
listener.clearLoadContinuation()
|
||||
}
|
||||
|
||||
interstitial.load(applicationContext)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn插页广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告缓存
|
||||
*/
|
||||
private fun destroyAd() {
|
||||
synchronized(adCache) {
|
||||
adCache.clear()
|
||||
}
|
||||
AdLogger.d("TopOn插页广告缓存已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn插页广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插页广告是否正在展示
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前缓存的广告对象(用于竞价)
|
||||
*/
|
||||
fun getCurrentAd(placementId: String? = null): com.thinkup.interstitial.api.TUInterstitial? {
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TopOn 插页广告监听器
|
||||
*/
|
||||
private inner class TopOnInterstitialListener(
|
||||
private val placementId: String,
|
||||
private val startLoadTime: Long,
|
||||
private val interstitial: TUInterstitial,
|
||||
private val applicationContext: Context
|
||||
) : TUInterstitialListener, TUAdRevenueListener {
|
||||
|
||||
private var loadContinuation: CancellableContinuation<TopOnAdEntry?>? = null
|
||||
private var showContinuation: CancellableContinuation<AdResult<Unit>>? = null
|
||||
private var lastAdInfo: TUAdInfo? = null
|
||||
private var cacheTime: Long = System.currentTimeMillis()
|
||||
|
||||
fun attachLoadContinuation(continuation: CancellableContinuation<TopOnAdEntry?>) {
|
||||
loadContinuation = continuation
|
||||
}
|
||||
|
||||
fun clearLoadContinuation() {
|
||||
loadContinuation = null
|
||||
}
|
||||
|
||||
private fun resumeLoad(entry: TopOnAdEntry?) {
|
||||
loadContinuation?.let { continuation ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(entry)
|
||||
}
|
||||
}
|
||||
loadContinuation = null
|
||||
}
|
||||
|
||||
private fun resumeShow(result: AdResult<Unit>) {
|
||||
showContinuation?.let { continuation ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
showContinuation = null
|
||||
}
|
||||
|
||||
suspend fun awaitShow(activity: Activity): AdResult<Unit> {
|
||||
if (!interstitial.isAdReady) {
|
||||
AdLogger.w("TopOn插页广告未准备好,展示终止,广告位ID: %s", placementId)
|
||||
return AdResult.Failure(createAdException("广告未准备好"))
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
showContinuation = continuation
|
||||
continuation.invokeOnCancellation {
|
||||
showContinuation = null
|
||||
}
|
||||
|
||||
try {
|
||||
interstitial.show(activity)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn插页广告调用show异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(
|
||||
AdResult.Failure(createAdException("显示失败: ${e.message}", e))
|
||||
)
|
||||
}
|
||||
showContinuation = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterstitialAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startLoadTime
|
||||
totalLoadSucCount++
|
||||
cacheTime = System.currentTimeMillis()
|
||||
|
||||
val adInfo = runCatching { interstitial.checkValidAdCaches().firstOrNull() }.getOrNull()
|
||||
|
||||
AdLogger.d(
|
||||
"TopOn插页广告加载成功,广告位ID: %s,耗时: %dms,缓存成功次数: %d",
|
||||
placementId,
|
||||
loadTime,
|
||||
totalLoadSucCount
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (adInfo?.networkName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
val entry = TopOnAdEntry(
|
||||
placementId = placementId,
|
||||
ad = interstitial,
|
||||
listener = this,
|
||||
cacheTime = cacheTime
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache[placementId] = entry
|
||||
}
|
||||
|
||||
resumeLoad(entry)
|
||||
}
|
||||
|
||||
override fun onInterstitialAdLoadFail(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startLoadTime
|
||||
AdLogger.e(
|
||||
"TopOn插页广告加载失败,广告位ID: %s,耗时: %dms,错误: %s",
|
||||
placementId,
|
||||
loadTime,
|
||||
adError.getFullErrorInfo()
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
resumeLoad(null)
|
||||
}
|
||||
|
||||
override fun onInterstitialAdShow(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn插页广告开始展示")
|
||||
isShowing = true
|
||||
lastAdInfo = adInfo
|
||||
AdConfigManager.getInterstitialConfig().recordShow()
|
||||
}
|
||||
|
||||
override fun onInterstitialAdClicked(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn插页广告被点击")
|
||||
totalClickCount++
|
||||
lastAdInfo = adInfo
|
||||
AdLogger.d("TopOn插页广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getInterstitialConfig().recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onInterstitialAdClose(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn插页广告关闭")
|
||||
isShowing = false
|
||||
totalCloseCount++
|
||||
lastAdInfo = adInfo
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache.remove(placementId)
|
||||
}
|
||||
|
||||
// 插页关闭时重新预缓存
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn插页广告关闭,开始重新预缓存,广告位ID: %s", placementId)
|
||||
preloadAd(applicationContext, placementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn插页广告重新预缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
resumeShow(AdResult.Success(Unit))
|
||||
}
|
||||
|
||||
override fun onInterstitialAdVideoError(adError: AdError) {
|
||||
AdLogger.w("TopOn插页广告展示失败: %s", adError.desc ?: adError.getFullErrorInfo())
|
||||
isShowing = false
|
||||
totalShowFailCount++
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"ad_source" to (lastAdInfo?.networkName ?: ""),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache.remove(placementId)
|
||||
}
|
||||
|
||||
resumeShow(AdResult.Failure(createAdException("显示失败: ${adError.desc ?: "unknown"}")))
|
||||
}
|
||||
|
||||
override fun onInterstitialAdVideoStart(adInfo: TUAdInfo) {
|
||||
// 无需额外处理
|
||||
}
|
||||
|
||||
override fun onInterstitialAdVideoEnd(adInfo: TUAdInfo) {
|
||||
// 无需额外处理
|
||||
}
|
||||
|
||||
override fun onAdRevenuePaid(adInfo: TUAdInfo) {
|
||||
lastAdInfo = adInfo
|
||||
totalShowCount++
|
||||
AdLogger.d(
|
||||
"TopOn插页广告收益回调,value=${adInfo.publisherRevenue ?: adInfo.ecpm}, currency=${adInfo.currency}"
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: 0.0
|
||||
// TopOn 的 revenueValue 已经是美元,不需要转换
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
|
||||
reportAdRevenueWithValue(adInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(adInfo: TUAdInfo) {
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = revenueValue,
|
||||
currencyCode = revenueCurrency
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = adInfo.placementId ?: "",
|
||||
adRevenuePlacement = adInfo.scenarioId ?: "",
|
||||
adFormat = "Interstitial"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"TopOn插页广告收益上报,placement=%s, revenue=%f %s",
|
||||
adInfo.placementId,
|
||||
revenueValue,
|
||||
revenueCurrency
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "Interstitial"
|
||||
)
|
||||
data.putAll(params)
|
||||
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析广告位ID
|
||||
*/
|
||||
private fun resolvePlacementId(placementId: String?): String {
|
||||
if (!placementId.isNullOrBlank()) {
|
||||
return placementId
|
||||
}
|
||||
|
||||
return BuildConfig.TOPON_INTERSTITIAL_ID
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.content.Context
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.thinkup.core.api.TUNetworkConfig
|
||||
import com.thinkup.core.api.TUSDK
|
||||
import com.thinkup.core.api.TUSDKInitListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* TopOn SDK 管理器
|
||||
* 负责SDK初始化和全局配置
|
||||
*/
|
||||
object TopOnManager {
|
||||
|
||||
private const val TAG = "TopOnManager"
|
||||
|
||||
private val _initializationState = MutableStateFlow<AdResult<Unit>>(AdResult.Loading)
|
||||
val initializationState: StateFlow<AdResult<Unit>> = _initializationState.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var isInitialized = false
|
||||
|
||||
/**
|
||||
* 初始化 TopOn SDK
|
||||
* @param context 上下文
|
||||
* @param appId TopOn App ID
|
||||
* @param appKey TopOn App Key
|
||||
*/
|
||||
suspend fun initialize(context: Context, appId: String, appKey: String): AdResult<Unit> {
|
||||
if (isInitialized) {
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
_initializationState.value = AdResult.Loading
|
||||
|
||||
try {
|
||||
TUSDK.setNetworkLogDebug(AdLogger.isLogEnabled())
|
||||
|
||||
TUSDK.init(
|
||||
applicationContext,
|
||||
appId,
|
||||
appKey,
|
||||
TUNetworkConfig(),
|
||||
object : TUSDKInitListener {
|
||||
override fun onSuccess() {
|
||||
AdLogger.d("TopOn SDK初始化完成,版本: ${runCatching { TUSDK.getSDKVersionName() }.getOrElse { "unknown" }}")
|
||||
isInitialized = true
|
||||
val result = AdResult.Success(Unit)
|
||||
_initializationState.value = result
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFail(errorMsg: String) {
|
||||
val message = errorMsg.ifBlank { "未知错误" }
|
||||
AdLogger.e("TopOn SDK初始化失败: %s", message)
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "SDK初始化失败: $message"
|
||||
)
|
||||
)
|
||||
_initializationState.value = result
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn SDK初始化过程中发生异常", e)
|
||||
val result = AdResult.Failure(
|
||||
AdException(
|
||||
code = AdException.ERROR_INTERNAL,
|
||||
message = "SDK初始化异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
_initializationState.value = result
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查SDK是否已初始化
|
||||
*/
|
||||
fun isInitialized(): Boolean {
|
||||
return isInitialized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前初始化状态
|
||||
*/
|
||||
fun getCurrentInitializationState(): AdResult<Unit> {
|
||||
return _initializationState.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取TopOn广告控制器
|
||||
*/
|
||||
object Controllers {
|
||||
val interstitial: TopOnInterstitialAdController
|
||||
get() = TopOnInterstitialAdController.getInstance()
|
||||
|
||||
val rewarded: TopOnRewardedAdController
|
||||
get() = TopOnRewardedAdController.getInstance()
|
||||
|
||||
val native: TopOnNativeAdController
|
||||
get() = TopOnNativeAdController.getInstance()
|
||||
|
||||
val splash: TopOnSplashAdController
|
||||
get() = TopOnSplashAdController.getInstance()
|
||||
|
||||
val fullScreenNative: TopOnFullScreenNativeAdController
|
||||
get() = TopOnFullScreenNativeAdController.getInstance()
|
||||
|
||||
val banner: TopOnBannerAdController
|
||||
get() = TopOnBannerAdController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有控制器资源
|
||||
*/
|
||||
fun destroyAll() {
|
||||
Controllers.interstitial.destroy()
|
||||
Controllers.rewarded.destroy()
|
||||
Controllers.native.destroy()
|
||||
Controllers.splash.destroy()
|
||||
Controllers.fullScreenNative.destroy()
|
||||
Controllers.banner.destroy()
|
||||
AdLogger.d("TopOn广告控制器已清理")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,629 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import com.blankj.utilcode.util.SizeUtils
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.topon.ToponNativeAdStyle
|
||||
import com.remax.bill.ui.topon.ToponNativeAdView
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdConst
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.nativead.api.TUNative
|
||||
import com.thinkup.nativead.api.TUNativeNetworkListener
|
||||
import com.thinkup.nativead.api.NativeAd
|
||||
import com.thinkup.nativead.api.TUNativeAdView
|
||||
import com.thinkup.nativead.api.TUNativeDislikeListener
|
||||
import com.thinkup.nativead.api.TUNativeEventListener
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn原生广告控制器
|
||||
* 提供原生广告的加载和管理功能
|
||||
* 参考文档: https://help.toponad.net/cn/docs/yuan-sheng-guang-gao
|
||||
*/
|
||||
class TopOnNativeAdController private constructor() {
|
||||
|
||||
// 累积点击统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_native_ad_total_clicks", 0)
|
||||
|
||||
// 累积关闭统计(持久化)
|
||||
private var totalCloseCount by KvIntDelegate("topon_native_ad_total_close", 0)
|
||||
|
||||
// 累积加载次数统计(持久化)
|
||||
private var totalLoadCount by KvIntDelegate("topon_native_ad_total_loads", 0)
|
||||
|
||||
// 累积加载成功次数统计(持久化)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_native_ad_total_load_suc", 0)
|
||||
|
||||
// 累积展示失败次数统计(持久化)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_native_ad_total_show_fails", 0)
|
||||
|
||||
// 累积触发统计(持久化)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_native_ad_total_show_triggers", 0)
|
||||
|
||||
// 累积展示统计(持久化)
|
||||
private var totalShowCount by KvIntDelegate("topon_native_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ToponNativeAdController"
|
||||
private const val AD_TIMEOUT = 1 * 60 * 60 * 1000L // 1小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnNativeAdController? = null
|
||||
|
||||
fun getInstance(): TopOnNativeAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnNativeAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedNativeAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val nativeAdView = ToponNativeAdView()
|
||||
|
||||
// 当前广告的收益信息(临时存储)
|
||||
private var currentAdInfo: TUAdInfo? = null
|
||||
|
||||
/**
|
||||
* 缓存的原生广告数据类
|
||||
*/
|
||||
private data class CachedNativeAd(
|
||||
val ad: TUNative,
|
||||
val placementId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载原生广告(可选,用于提前准备)
|
||||
* @param context 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, placementId: String? = null): AdResult<Unit> {
|
||||
if(!GlobalAdSwitchInterceptor.isGlobalAdEnabled()){
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "原生广告全局开关已关闭,中断加载"
|
||||
))
|
||||
}
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_NATIVE_ID
|
||||
return loadAdToCache(context, finalPlacementId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原生广告(自动处理加载)
|
||||
* @param context 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun getAd(context: Context, placementId: String? = null): AdResult<TUNative> {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_NATIVE_ID
|
||||
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalPlacementId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载TopOn原生广告,广告位ID: %s", finalPlacementId)
|
||||
loadAdToCache(context, finalPlacementId)
|
||||
cachedAd = getCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
return if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的TopOn原生广告,广告位ID: %s", finalPlacementId)
|
||||
AdResult.Success(cachedAd.ad)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示原生广告到指定容器(简化版接口)
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param style 广告样式,默认为标准样式
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
* @return 是否显示成功
|
||||
*/
|
||||
suspend fun showAdInContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
style: ToponNativeAdStyle = ToponNativeAdStyle.STANDARD,
|
||||
placementId: String? = null
|
||||
): Boolean {
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_NATIVE_ID
|
||||
|
||||
// 累积触发统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("TopOn原生广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(context, AdConfigManager.getNativeConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("TopOn原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.w("TopOn原生广告拦截器检查失败: %s", interceptResult.error.message)
|
||||
return false
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
return try {
|
||||
when (val result = getAd(context, placementId)) {
|
||||
is AdResult.Success -> {
|
||||
val tuNative = result.data
|
||||
val nativeAd = tuNative.getNativeAd()
|
||||
if (nativeAd == null) {
|
||||
AdLogger.e("TopOn原生广告获取NativeAd失败")
|
||||
return false
|
||||
}
|
||||
// 设置事件监听器
|
||||
nativeAd.setNativeEventListener(createNativeEventListener(finalPlacementId, nativeAd))
|
||||
// 当有显示关闭按钮时点击回调,并非关闭弹出的页面
|
||||
nativeAd.setDislikeCallbackListener(object :TUNativeDislikeListener(){
|
||||
override fun onAdCloseButtonClick(
|
||||
p0: TUNativeAdView?,
|
||||
adInfo: TUAdInfo
|
||||
) {
|
||||
AdLogger.d("TopOn原生广告关闭")
|
||||
currentAdInfo = adInfo
|
||||
totalCloseCount++
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
// 设置容器高度(如果style指定了高度)
|
||||
if (style.heightDp > 0) {
|
||||
val heightPx = SizeUtils.dp2px(style.heightDp.toFloat())
|
||||
container.layoutParams = container.layoutParams?.apply {
|
||||
height = heightPx
|
||||
}
|
||||
}
|
||||
// 绑定广告到容器
|
||||
nativeAdView.bindNativeAdToContainer(context, container, nativeAd, style)
|
||||
true
|
||||
}
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("TopOn原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to result.error.message
|
||||
)
|
||||
)
|
||||
false
|
||||
}
|
||||
AdResult.Loading -> {
|
||||
// 保持加载状态
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("TopOn原生广告累积展示失败次数: $totalShowFailCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "${e.message}"
|
||||
)
|
||||
)
|
||||
|
||||
AdLogger.e("显示TopOn原生广告失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, placementId: String): TUNative? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn原生广告开始加载,广告位ID: %s,当前累计加载次数: %d", placementId, totalLoadCount)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
// 将 tuNative 定义在外部作用域,以便在回调中访问
|
||||
var tuNative: TUNative? = null
|
||||
|
||||
try {
|
||||
tuNative = TUNative(applicationContext, placementId, object : TUNativeNetworkListener {
|
||||
override fun onNativeAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
totalLoadSucCount++
|
||||
|
||||
AdLogger.d("TopOn原生广告加载成功,广告位ID: %s, 耗时: %dms", placementId, loadTime)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
// 直接返回 TUNative
|
||||
continuation.resume(tuNative)
|
||||
}
|
||||
|
||||
override fun onNativeAdLoadFail(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("TopOn原生广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", placementId, loadTime, adError.getFullErrorInfo())
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
|
||||
// 发起广告请求
|
||||
tuNative.makeAdRequest()
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn原生广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建原生广告事件监听器
|
||||
*/
|
||||
private fun createNativeEventListener(
|
||||
placementId: String,
|
||||
nativeAd: NativeAd
|
||||
): TUNativeEventListener {
|
||||
return object : TUNativeEventListener {
|
||||
override fun onAdImpressed(view: com.thinkup.nativead.api.TUNativeAdView, adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn原生广告展示完成")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 累积展示统计
|
||||
totalShowCount++
|
||||
AdLogger.d("TopOn原生广告累积展示次数: $totalShowCount")
|
||||
|
||||
// 记录展示
|
||||
AdConfigManager.getNativeConfig().recordShow()
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
|
||||
// TopOn 的 revenueValue 已经是美元,不需要转换
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
|
||||
reportAdRevenueWithValue(placementId, adInfo)
|
||||
|
||||
// 异步预加载下一个广告到缓存(如果缓存未满)
|
||||
if (!isCacheFull(placementId)) {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
preloadAd(view.context, placementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn原生广告预加载失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdClicked(view: com.thinkup.nativead.api.TUNativeAdView, adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn原生广告被点击")
|
||||
currentAdInfo = adInfo
|
||||
|
||||
// 累积点击统计
|
||||
totalClickCount++
|
||||
AdLogger.d("TopOn原生广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getNativeConfig().recordClick()
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onAdVideoStart(p0: TUNativeAdView?) {
|
||||
}
|
||||
|
||||
override fun onAdVideoEnd(p0: TUNativeAdView?) {
|
||||
}
|
||||
|
||||
override fun onAdVideoProgress(
|
||||
p0: TUNativeAdView?,
|
||||
p1: Int
|
||||
) {
|
||||
}
|
||||
|
||||
fun onAdClosed(view: com.thinkup.nativead.api.TUNativeAdView, adInfo: TUAdInfo) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(context: Context, placementId: String): AdResult<Unit> {
|
||||
return try {
|
||||
|
||||
// 检查缓存是否已满
|
||||
val currentPlacementCount = getCachedAdCount(placementId)
|
||||
if (currentPlacementCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", placementId, currentPlacementCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val tuNative = loadAd(context, placementId)
|
||||
if (tuNative != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedNativeAd(tuNative, placementId))
|
||||
val currentCount = getCachedAdCount(placementId)
|
||||
AdLogger.d("TopOn原生广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", placementId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn原生loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
fun peekCachedAd(placementId: String = BuildConfig.TOPON_NATIVE_ID): TUNative? {
|
||||
return synchronized(adCachePool) {
|
||||
adCachePool.firstOrNull { it.placementId == placementId && !it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(placementId: String): CachedNativeAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.placementId == placementId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(placementId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.placementId == placementId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(placementId: String): Boolean {
|
||||
return getCachedAdCount(placementId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载的广告数据
|
||||
*/
|
||||
fun getCurrentAd(): TUNative? {
|
||||
return getCachedAd(BuildConfig.TOPON_NATIVE_ID)?.ad
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可用的广告
|
||||
*/
|
||||
fun isAdLoaded(): Boolean {
|
||||
return getCachedAdCount(BuildConfig.TOPON_NATIVE_ID) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.forEach { cachedAd ->
|
||||
// TopOn 原生广告不需要显式销毁
|
||||
}
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("TopOn原生广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param adInfo 广告信息
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(placementId: String, adInfo: TUAdInfo) {
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = revenueValue,
|
||||
currencyCode = revenueCurrency
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = placementId,
|
||||
adRevenuePlacement = adInfo.scenarioId ?: "",
|
||||
adFormat = "Native"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("TopOn原生广告真实收益数据已上报,广告位ID: ${placementId}, 收益: %.8f %s", revenueValue, revenueCurrency)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn原生广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "Native"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if(eventName == "ad_impression"){
|
||||
DataReportManager.reportDataByName("ThinkingData",eventName, data)
|
||||
} else{
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,627 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.bill.ui.dialog.ADLoadingDialog
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.remax.bill.ads.PreloadController
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.core.api.TUAdRevenueListener
|
||||
import com.thinkup.rewardvideo.api.TURewardVideoAd
|
||||
import com.thinkup.rewardvideo.api.TURewardVideoListener
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn 激励广告控制器
|
||||
* 参考 AdMob 激励广告控制器实现,保持埋点和收益上报一致
|
||||
*/
|
||||
class TopOnRewardedAdController private constructor() {
|
||||
|
||||
// 累积统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_rewarded_ad_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("topon_rewarded_ad_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("topon_rewarded_ad_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_rewarded_ad_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_rewarded_ad_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_rewarded_ad_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("topon_rewarded_ad_total_shows", 0)
|
||||
private var totalRewardEarnedCount by KvIntDelegate("topon_rewarded_ad_total_reward_earned", 0)
|
||||
|
||||
// 是否正在展示
|
||||
@Volatile
|
||||
private var isShowing: Boolean = false
|
||||
|
||||
companion object {
|
||||
private const val CACHE_EXPIRE_MS = 60 * 60 * 1000L
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnRewardedAdController? = null
|
||||
|
||||
fun getInstance(): TopOnRewardedAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnRewardedAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val interceptorChain = InterceptorChain(
|
||||
listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
private val adCache = mutableMapOf<String, TopOnRewardedAdEntry>()
|
||||
|
||||
/**
|
||||
* 缓存的激励广告实体
|
||||
*/
|
||||
private data class TopOnRewardedAdEntry(
|
||||
val placementId: String,
|
||||
val ad: TURewardVideoAd,
|
||||
val listener: TopOnRewardedVideoListener,
|
||||
val cacheTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - cacheTime > CACHE_EXPIRE_MS || !ad.isAdReady
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载广告
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, placementId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "全局广告开关关闭,终止加载"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
AdLogger.w("TopOn激励广告缺少有效的广告位ID,无法预加载")
|
||||
return AdResult.Failure(createAdException("广告位ID缺失"))
|
||||
}
|
||||
|
||||
val cached = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
if (cached != null) {
|
||||
AdLogger.d("TopOn激励广告已有有效缓存,广告位ID: %s", finalPlacementId)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
return if (loadAd(context, finalPlacementId) != null) {
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示广告
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
placementId: String? = null,
|
||||
onRewardEarned: ((String, Int) -> Unit)? = null
|
||||
): AdResult<Unit> {
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
return AdResult.Failure(createAdException("广告位ID缺失"))
|
||||
}
|
||||
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("TopOn激励广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to finalPlacementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查(激励广告通常不拦截,但保留逻辑)
|
||||
// when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getRewardedConfig())) {
|
||||
// is AdResult.Failure -> {
|
||||
// totalShowFailCount++
|
||||
// AdLogger.d("TopOn激励广告拦截后累积展示失败次数: $totalShowFailCount")
|
||||
// reportAdData(
|
||||
// eventName = "ad_show_fail",
|
||||
// params = mapOf(
|
||||
// "ad_unit_name" to finalPlacementId,
|
||||
// "position" to PositionGet.get(),
|
||||
// "number" to totalShowFailCount,
|
||||
// "reason" to interceptResult.error.message
|
||||
// )
|
||||
// )
|
||||
// return interceptResult
|
||||
// }
|
||||
// else -> Unit
|
||||
// }
|
||||
|
||||
return try {
|
||||
var entry = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
|
||||
if (entry == null) {
|
||||
ADLoadingDialog.show(activity)
|
||||
loadAd(activity, finalPlacementId)
|
||||
entry = synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
if (entry != null && entry.ad.isAdReady) {
|
||||
ADLoadingDialog.hide()
|
||||
AdLogger.d("TopOn使用缓存激励广告展示,广告位ID: %s", finalPlacementId)
|
||||
entry.listener.awaitShow(activity, onRewardEarned)
|
||||
} else {
|
||||
ADLoadingDialog.hide()
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn激励广告展示异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
} finally {
|
||||
ADLoadingDialog.hide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, placementId: String): TopOnRewardedAdEntry? {
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn激励广告开始加载,广告位ID: %s,当前累计加载次数: %d", placementId, totalLoadCount)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
try {
|
||||
val applicationContext = context.applicationContext
|
||||
val rewardedVideoAd = TURewardVideoAd(applicationContext, placementId)
|
||||
val listener = TopOnRewardedVideoListener(
|
||||
placementId = placementId,
|
||||
startLoadTime = System.currentTimeMillis(),
|
||||
rewardedVideoAd = rewardedVideoAd,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
listener.attachLoadContinuation(continuation)
|
||||
|
||||
rewardedVideoAd.setAdListener(listener)
|
||||
rewardedVideoAd.setAdRevenueListener(listener)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
listener.clearLoadContinuation()
|
||||
}
|
||||
|
||||
rewardedVideoAd.load(applicationContext)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn激励广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告缓存
|
||||
*/
|
||||
private fun destroyAd() {
|
||||
synchronized(adCache) {
|
||||
adCache.clear()
|
||||
}
|
||||
AdLogger.d("TopOn激励广告缓存已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn激励广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激励广告是否正在展示
|
||||
*/
|
||||
fun isAdShowing(): Boolean {
|
||||
return isShowing
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前缓存的广告对象(用于竞价)
|
||||
*/
|
||||
fun getCurrentAd(placementId: String? = null): com.thinkup.rewardvideo.api.TURewardVideoAd? {
|
||||
val finalPlacementId = resolvePlacementId(placementId)
|
||||
if (finalPlacementId.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return synchronized(adCache) {
|
||||
adCache[finalPlacementId]?.takeUnless { it.isExpired() }?.ad
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TopOn 激励视频广告监听器
|
||||
*/
|
||||
private inner class TopOnRewardedVideoListener(
|
||||
private val placementId: String,
|
||||
private val startLoadTime: Long,
|
||||
private val rewardedVideoAd: TURewardVideoAd,
|
||||
private val applicationContext: Context
|
||||
) : TURewardVideoListener, TUAdRevenueListener {
|
||||
|
||||
private var loadContinuation: kotlinx.coroutines.CancellableContinuation<TopOnRewardedAdEntry?>? = null
|
||||
private var showContinuation: kotlinx.coroutines.CancellableContinuation<AdResult<Unit>>? = null
|
||||
private var lastAdInfo: TUAdInfo? = null
|
||||
private var cacheTime: Long = System.currentTimeMillis()
|
||||
private var hasRewarded: Boolean = false
|
||||
private var rewardCallback: ((String, Int) -> Unit)? = null
|
||||
|
||||
fun attachLoadContinuation(continuation: kotlinx.coroutines.CancellableContinuation<TopOnRewardedAdEntry?>) {
|
||||
loadContinuation = continuation
|
||||
}
|
||||
|
||||
fun clearLoadContinuation() {
|
||||
loadContinuation = null
|
||||
}
|
||||
|
||||
private fun resumeLoad(entry: TopOnRewardedAdEntry?) {
|
||||
loadContinuation?.let { continuation ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(entry)
|
||||
}
|
||||
}
|
||||
loadContinuation = null
|
||||
}
|
||||
|
||||
private fun resumeShow(result: AdResult<Unit>) {
|
||||
showContinuation?.let { continuation ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
showContinuation = null
|
||||
}
|
||||
|
||||
suspend fun awaitShow(activity: Activity, onRewardEarned: ((String, Int) -> Unit)?): AdResult<Unit> {
|
||||
if (!rewardedVideoAd.isAdReady) {
|
||||
AdLogger.w("TopOn激励广告未准备好,展示终止,广告位ID: %s", placementId)
|
||||
return AdResult.Failure(createAdException("广告未准备好"))
|
||||
}
|
||||
|
||||
rewardCallback = onRewardEarned
|
||||
hasRewarded = false
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
showContinuation = continuation
|
||||
continuation.invokeOnCancellation {
|
||||
showContinuation = null
|
||||
}
|
||||
|
||||
try {
|
||||
rewardedVideoAd.show(activity)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn激励广告调用show异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(
|
||||
AdResult.Failure(createAdException("显示失败: ${e.message}", e))
|
||||
)
|
||||
}
|
||||
showContinuation = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startLoadTime
|
||||
totalLoadSucCount++
|
||||
cacheTime = System.currentTimeMillis()
|
||||
|
||||
val adInfo = runCatching { rewardedVideoAd.checkValidAdCaches().firstOrNull() }.getOrNull()
|
||||
|
||||
AdLogger.d(
|
||||
"TopOn激励广告加载成功,广告位ID: %s,耗时: %dms,缓存成功次数: %d",
|
||||
placementId,
|
||||
loadTime,
|
||||
totalLoadSucCount
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to (adInfo?.networkName.orEmpty()),
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
|
||||
val entry = TopOnRewardedAdEntry(
|
||||
placementId = placementId,
|
||||
ad = rewardedVideoAd,
|
||||
listener = this,
|
||||
cacheTime = cacheTime
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache[placementId] = entry
|
||||
}
|
||||
|
||||
resumeLoad(entry)
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdFailed(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startLoadTime
|
||||
AdLogger.e(
|
||||
"TopOn激励广告加载失败,广告位ID: %s,耗时: %dms,错误: %s",
|
||||
placementId,
|
||||
loadTime,
|
||||
adError.getFullErrorInfo()
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
resumeLoad(null)
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdPlayStart(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn激励广告开始播放")
|
||||
isShowing = true
|
||||
lastAdInfo = adInfo
|
||||
AdConfigManager.getRewardedConfig()?.recordShow()
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdPlayEnd(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn激励广告播放结束")
|
||||
lastAdInfo = adInfo
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdPlayFailed(adError: AdError, adInfo: TUAdInfo?) {
|
||||
AdLogger.w("TopOn激励广告播放失败: %s", adError.desc ?: adError.getFullErrorInfo())
|
||||
isShowing = false
|
||||
totalShowFailCount++
|
||||
lastAdInfo = adInfo
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"ad_source" to (adInfo?.networkName ?: ""),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache.remove(placementId)
|
||||
}
|
||||
|
||||
resumeShow(AdResult.Failure(createAdException("播放失败: ${adError.desc ?: "unknown"}")))
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdClosed(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn激励广告关闭")
|
||||
isShowing = false
|
||||
totalCloseCount++
|
||||
lastAdInfo = adInfo
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: ""),
|
||||
"isended" to if (hasRewarded) "true" else ""
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(adCache) {
|
||||
adCache.remove(placementId)
|
||||
}
|
||||
|
||||
// 激励关闭时重新预缓存
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
AdLogger.d("TopOn激励广告关闭,开始重新预缓存,广告位ID: %s", placementId)
|
||||
preloadAd(applicationContext, placementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn激励广告重新预缓存失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
resumeShow(AdResult.Success(Unit))
|
||||
}
|
||||
|
||||
override fun onRewardedVideoAdPlayClicked(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn激励广告被点击")
|
||||
totalClickCount++
|
||||
lastAdInfo = adInfo
|
||||
AdLogger.d("TopOn激励广告累积点击次数: $totalClickCount")
|
||||
|
||||
AdConfigManager.getRewardedConfig()?.recordClick()
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReward(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn用户获得奖励")
|
||||
hasRewarded = true
|
||||
totalRewardEarnedCount++
|
||||
lastAdInfo = adInfo
|
||||
AdLogger.d("TopOn激励广告累积奖励获得次数: $totalRewardEarnedCount")
|
||||
|
||||
val rewardType = adInfo.scenarioRewardName ?: "default"
|
||||
val rewardAmount = adInfo.scenarioRewardNumber
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_reward_earned",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalRewardEarnedCount,
|
||||
"reward_type" to rewardType,
|
||||
"reward_amount" to rewardAmount,
|
||||
"ad_source" to (adInfo.networkName ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
rewardCallback?.invoke(rewardType, rewardAmount)
|
||||
}
|
||||
|
||||
override fun onAdRevenuePaid(adInfo: TUAdInfo) {
|
||||
lastAdInfo = adInfo
|
||||
totalShowCount++
|
||||
AdLogger.d(
|
||||
"TopOn激励广告收益回调,value=${adInfo.publisherRevenue ?: adInfo.ecpm}, currency=${adInfo.currency}"
|
||||
)
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "")
|
||||
)
|
||||
)
|
||||
|
||||
val revenueValue = adInfo.publisherRevenue ?: 0.0
|
||||
// TopOn 的 revenueValue 已经是美元,不需要转换
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
|
||||
reportAdRevenueWithValue(adInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(adInfo: TUAdInfo) {
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = revenueValue,
|
||||
currencyCode = revenueCurrency
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = adInfo.placementId ?: "",
|
||||
adRevenuePlacement = adInfo.scenarioId ?: "",
|
||||
adFormat = "Rewarded"
|
||||
)
|
||||
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d(
|
||||
"TopOn激励广告收益上报,placement=%s, revenue=%f %s",
|
||||
adInfo.placementId,
|
||||
revenueValue,
|
||||
revenueCurrency
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "Rewarded"
|
||||
)
|
||||
data.putAll(params)
|
||||
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析广告位ID
|
||||
*/
|
||||
private fun resolvePlacementId(placementId: String?): String {
|
||||
if (!placementId.isNullOrBlank()) {
|
||||
return placementId
|
||||
}
|
||||
|
||||
return BuildConfig.TOPON_REWARDED_ID
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
package com.remax.bill.ads.topon
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.config.AdConfigManager
|
||||
import com.remax.bill.ads.interceptor.ClickLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.GlobalAdSwitchInterceptor
|
||||
import com.remax.bill.ads.interceptor.InterceptorChain
|
||||
import com.remax.bill.ads.interceptor.ShowCountLimitInterceptor
|
||||
import com.remax.bill.ads.interceptor.ShowIntervalLimitInterceptor
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.util.PositionGet
|
||||
import com.remax.base.ads.AdRevenueData
|
||||
import com.remax.base.ads.AdRevenueManager
|
||||
import com.remax.base.ads.RevenueInfo
|
||||
import com.remax.base.ext.KvIntDelegate
|
||||
import com.remax.base.report.DataReportManager
|
||||
import com.thinkup.core.api.AdError
|
||||
import com.thinkup.core.api.TUAdInfo
|
||||
import com.thinkup.splashad.api.TUSplashAd
|
||||
import com.thinkup.splashad.api.TUSplashAdEZListener
|
||||
import com.thinkup.splashad.api.TUSplashAdExtraInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* TopOn 开屏广告控制器
|
||||
* 参考 AdMob 开屏广告控制器实现,保持埋点一致
|
||||
*/
|
||||
class TopOnSplashAdController private constructor() {
|
||||
|
||||
// 累积统计(持久化)
|
||||
private var totalClickCount by KvIntDelegate("topon_splash_ad_total_clicks", 0)
|
||||
private var totalCloseCount by KvIntDelegate("topon_splash_ad_total_close", 0)
|
||||
private var totalLoadCount by KvIntDelegate("topon_splash_ad_total_loads", 0)
|
||||
private var totalLoadSucCount by KvIntDelegate("topon_splash_ad_total_load_suc", 0)
|
||||
private var totalShowFailCount by KvIntDelegate("topon_splash_ad_total_show_fails", 0)
|
||||
private var totalShowTriggerCount by KvIntDelegate("topon_splash_ad_total_show_triggers", 0)
|
||||
private var totalShowCount by KvIntDelegate("topon_splash_ad_total_shows", 0)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TopOnSplashAdController"
|
||||
private const val AD_TIMEOUT = 4 * 60 * 60 * 1000L // 4小时过期
|
||||
private const val DEFAULT_CACHE_SIZE_PER_AD_UNIT = 1
|
||||
private const val DEFAULT_FETCH_AD_TIMEOUT = 8000 // 8秒超时
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: TopOnSplashAdController? = null
|
||||
|
||||
fun getInstance(): TopOnSplashAdController {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: TopOnSplashAdController().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内存缓存池 - 存储预加载的广告
|
||||
private val adCachePool = mutableListOf<CachedSplashAd>()
|
||||
private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT
|
||||
|
||||
// 存储每个 placementId 对应的 continuation,用于在 onAdDismiss 回调中恢复
|
||||
private val continuationMap = mutableMapOf<String, kotlinx.coroutines.CancellableContinuation<AdResult<Unit>>>()
|
||||
|
||||
// 拦截器链
|
||||
private val interceptorChain = InterceptorChain(
|
||||
interceptors = listOf(
|
||||
GlobalAdSwitchInterceptor(),
|
||||
ShowCountLimitInterceptor(),
|
||||
ShowIntervalLimitInterceptor(),
|
||||
ClickLimitInterceptor()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 缓存的开屏广告数据类
|
||||
*/
|
||||
private data class CachedSplashAd(
|
||||
val splashAd: TUSplashAd,
|
||||
val placementId: String,
|
||||
val loadTime: Long = System.currentTimeMillis()
|
||||
) {
|
||||
fun isExpired(): Boolean {
|
||||
return System.currentTimeMillis() - loadTime > AD_TIMEOUT || !splashAd.isAdReady
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载开屏广告
|
||||
* @param context 上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
*/
|
||||
suspend fun preloadAd(context: Context, placementId: String? = null): AdResult<Unit> {
|
||||
if (!GlobalAdSwitchInterceptor.isGlobalAdEnabled()) {
|
||||
return AdResult.Failure(
|
||||
AdException(
|
||||
code = -100,
|
||||
message = "开屏全局广告已关闭,中断加载"
|
||||
)
|
||||
)
|
||||
}
|
||||
val finalPlacementId = placementId ?: BuildConfig.TOPON_SPLASH_ID
|
||||
return loadAdToCache(context, finalPlacementId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础广告加载方法(可复用)
|
||||
*/
|
||||
private suspend fun loadAd(context: Context, placementId: String, fetchAdTimeout: Int = DEFAULT_FETCH_AD_TIMEOUT): TUSplashAd? {
|
||||
// 累积加载次数统计
|
||||
totalLoadCount++
|
||||
AdLogger.d("TopOn开屏广告累积加载次数: $totalLoadCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_start_load",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadCount
|
||||
)
|
||||
)
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val startTime = System.currentTimeMillis()
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
// 将 splashAd 声明在外部作用域,以便在回调中访问
|
||||
var splashAd: TUSplashAd? = null
|
||||
|
||||
try {
|
||||
splashAd = TUSplashAd(
|
||||
applicationContext,
|
||||
placementId,
|
||||
object : TUSplashAdEZListener() {
|
||||
override fun onAdLoaded() {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.d("TopOn开屏广告加载成功,广告位ID: %s, 耗时: %dms", placementId, loadTime)
|
||||
totalLoadSucCount++
|
||||
reportAdData(
|
||||
eventName = "ad_loaded",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt()
|
||||
)
|
||||
)
|
||||
continuation.resume(splashAd)
|
||||
}
|
||||
|
||||
override fun onNoAdError(adError: AdError) {
|
||||
val loadTime = System.currentTimeMillis() - startTime
|
||||
AdLogger.e("TopOn开屏广告加载失败,广告位ID: %s, 耗时: %dms, 错误: %s", placementId, loadTime, adError.getFullErrorInfo())
|
||||
reportAdData(
|
||||
eventName = "ad_load_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"number" to totalLoadSucCount,
|
||||
"ad_source" to "",
|
||||
"pass_time" to ceil(loadTime / 1000.0).toInt(),
|
||||
"reason" to (adError.desc ?: adError.getFullErrorInfo())
|
||||
)
|
||||
)
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun onAdShow(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn开屏广告开始显示")
|
||||
totalShowCount++
|
||||
AdLogger.d("TopOn开屏广告累积展示次数: $totalShowCount")
|
||||
AdConfigManager.getAppOpenConfig().recordShow()
|
||||
|
||||
// 处理收益信息
|
||||
val revenueValue = adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0
|
||||
val revenueCurrency = adInfo.currency ?: "USD"
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_impression",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to revenueValue,
|
||||
"currency" to revenueCurrency
|
||||
)
|
||||
)
|
||||
|
||||
// 上报真实的广告收益数据
|
||||
splashAd?.let {
|
||||
reportAdRevenueWithValue(it, adInfo, placementId)
|
||||
}
|
||||
|
||||
// TopOn 的 revenueValue 已经是美元,直接使用
|
||||
val revenueUsd = revenueValue.toLong()
|
||||
}
|
||||
|
||||
override fun onAdClick(adInfo: TUAdInfo) {
|
||||
AdLogger.d("TopOn开屏广告被点击")
|
||||
totalClickCount++
|
||||
AdLogger.d("TopOn开屏广告累积点击次数: $totalClickCount")
|
||||
AdConfigManager.getAppOpenConfig().recordClick()
|
||||
reportAdData(
|
||||
eventName = "ad_click",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalClickCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: 0.0),
|
||||
"currency" to "USD"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override fun onAdDismiss(adInfo: TUAdInfo, splashAdExtraInfo: TUSplashAdExtraInfo) {
|
||||
AdLogger.d("TopOn开屏广告关闭")
|
||||
totalCloseCount++
|
||||
reportAdData(
|
||||
eventName = "ad_close",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalCloseCount,
|
||||
"ad_source" to (adInfo.networkName ?: ""),
|
||||
"value" to (adInfo.publisherRevenue ?: adInfo.ecpm ?: 0.0),
|
||||
"currency" to (adInfo.currency ?: "USD")
|
||||
)
|
||||
)
|
||||
|
||||
// 恢复 continuation(在 showAdInternal 中设置)
|
||||
synchronized(continuationMap) {
|
||||
continuationMap.remove(placementId)?.let { cont ->
|
||||
if (cont.isActive) {
|
||||
cont.resume(AdResult.Success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 广告关闭后,如果缓存未满,异步预加载下一个广告
|
||||
if (!isCacheFull(placementId)) {
|
||||
AdLogger.d("TopOn开屏广告关闭后开始异步预加载下一个广告,广告位ID: %s", placementId)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
preloadAd(applicationContext, placementId)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn开屏广告预加载失败", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
fetchAdTimeout
|
||||
)
|
||||
|
||||
splashAd.loadAd()
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn开屏广告加载异常", e)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载广告到缓存
|
||||
*/
|
||||
suspend fun loadAdToCache(context: Context, placementId: String): AdResult<Unit> {
|
||||
return try {
|
||||
// 检查缓存是否已满
|
||||
val currentPlacementCount = getCachedAdCount(placementId)
|
||||
if (currentPlacementCount >= maxCacheSizePerAdUnit) {
|
||||
AdLogger.w("广告位 %s 缓存已满,当前缓存: %d/%d", placementId, currentPlacementCount, maxCacheSizePerAdUnit)
|
||||
return AdResult.Success(Unit)
|
||||
}
|
||||
|
||||
// 加载广告
|
||||
val splashAd = loadAd(context, placementId)
|
||||
if (splashAd != null) {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.add(CachedSplashAd(splashAd, placementId))
|
||||
val currentCount = getCachedAdCount(placementId)
|
||||
AdLogger.d("TopOn开屏广告加载成功并缓存,广告位ID: %s,该广告位缓存数量: %d/%d", placementId, currentCount, maxCacheSizePerAdUnit)
|
||||
}
|
||||
AdResult.Success(Unit)
|
||||
} else {
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn开屏loadAdToCache异常", e)
|
||||
AdResult.Failure(AdException(0, "加载异常: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示开屏广告(自动处理加载和过期检查)
|
||||
* @param activity Activity上下文
|
||||
* @param placementId 广告位ID,如果为空则使用默认ID
|
||||
* @param onLoaded 加载状态回调
|
||||
*/
|
||||
suspend fun showAd(
|
||||
activity: Activity,
|
||||
placementId: String = BuildConfig.TOPON_SPLASH_ID,
|
||||
onLoaded: ((isSuc: Boolean) -> Unit)? = null
|
||||
): AdResult<Unit> {
|
||||
// 累积触发广告展示次数统计
|
||||
totalShowTriggerCount++
|
||||
AdLogger.d("TopOn开屏广告累积触发展示次数: $totalShowTriggerCount")
|
||||
|
||||
reportAdData(
|
||||
eventName = "ad_position",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowTriggerCount
|
||||
)
|
||||
)
|
||||
|
||||
// 拦截器检查
|
||||
when (val interceptResult = interceptorChain.intercept(activity, AdConfigManager.getAppOpenConfig())) {
|
||||
is AdResult.Failure -> {
|
||||
// 累积展示失败次数统计
|
||||
totalShowFailCount++
|
||||
AdLogger.d("TopOn开屏广告累积展示失败次数: $totalShowFailCount")
|
||||
onLoaded?.invoke(false)
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to interceptResult.error.message,
|
||||
)
|
||||
)
|
||||
return interceptResult
|
||||
}
|
||||
else -> { /* continue */ }
|
||||
}
|
||||
|
||||
val finalPlacementId = placementId
|
||||
val adResult = try {
|
||||
// 1. 尝试从缓存获取广告
|
||||
var cachedAd = getCachedAd(finalPlacementId)
|
||||
|
||||
// 2. 如果缓存为空,立即加载并缓存一个广告
|
||||
if (cachedAd == null) {
|
||||
AdLogger.d("缓存为空,立即加载TopOn开屏广告,广告位ID: %s", finalPlacementId)
|
||||
loadAdToCache(activity, finalPlacementId)
|
||||
cachedAd = getCachedAd(finalPlacementId)
|
||||
}
|
||||
|
||||
if (cachedAd != null) {
|
||||
AdLogger.d("使用缓存中的TopOn开屏广告,广告位ID: %s", finalPlacementId)
|
||||
onLoaded?.invoke(true)
|
||||
|
||||
// 3. 获取容器并显示广告
|
||||
val container = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||
?: activity.window.decorView as ViewGroup
|
||||
val result = showAdInternal(activity, container, cachedAd.splashAd, finalPlacementId)
|
||||
|
||||
result
|
||||
} else {
|
||||
onLoaded?.invoke(false)
|
||||
AdResult.Failure(createAdException("广告加载失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("显示TopOn开屏广告异常", e)
|
||||
AdResult.Failure(createAdException("显示广告异常: ${e.message}", e))
|
||||
}
|
||||
|
||||
return adResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存获取广告
|
||||
*/
|
||||
private fun getCachedAd(placementId: String): CachedSplashAd? {
|
||||
synchronized(adCachePool) {
|
||||
val index = adCachePool.indexOfFirst { it.placementId == placementId && !it.isExpired() }
|
||||
return if (index != -1) {
|
||||
adCachePool.removeAt(index)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekCachedAd(placementId: String = BuildConfig.TOPON_SPLASH_ID): TUSplashAd? {
|
||||
return synchronized(adCachePool) {
|
||||
adCachePool.firstOrNull { it.placementId == placementId && !it.isExpired() }?.splashAd
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定广告位的缓存数量
|
||||
*/
|
||||
private fun getCachedAdCount(placementId: String): Int {
|
||||
synchronized(adCachePool) {
|
||||
return adCachePool.count { it.placementId == placementId && !it.isExpired() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定广告位缓存是否已满
|
||||
*/
|
||||
private fun isCacheFull(placementId: String): Boolean {
|
||||
return getCachedAdCount(placementId) >= maxCacheSizePerAdUnit
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示广告的内部实现
|
||||
*/
|
||||
private suspend fun showAdInternal(
|
||||
activity: Activity,
|
||||
container: ViewGroup,
|
||||
splashAd: TUSplashAd,
|
||||
placementId: String
|
||||
): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
try {
|
||||
// 检查广告是否准备好
|
||||
if (!splashAd.isAdReady) {
|
||||
AdLogger.w("TopOn开屏广告未准备好,广告位ID: %s", placementId)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to "广告未准备好"
|
||||
)
|
||||
)
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AdResult.Failure(createAdException("广告未准备好")))
|
||||
}
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
|
||||
// 存储 continuation,以便在 onAdDismiss 回调中使用
|
||||
synchronized(continuationMap) {
|
||||
continuationMap[placementId] = continuation
|
||||
}
|
||||
|
||||
// 显示广告
|
||||
// onAdDismiss 回调已在 loadAd 中设置,会在广告关闭时恢复 continuation
|
||||
splashAd.show(activity, container)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("TopOn开屏广告显示异常", e)
|
||||
totalShowFailCount++
|
||||
reportAdData(
|
||||
eventName = "ad_show_fail",
|
||||
params = mapOf(
|
||||
"ad_unit_name" to placementId,
|
||||
"position" to PositionGet.get(),
|
||||
"number" to totalShowFailCount,
|
||||
"reason" to (e.message ?: "显示异常")
|
||||
)
|
||||
)
|
||||
// 清理 continuation
|
||||
synchronized(continuationMap) {
|
||||
continuationMap.remove(placementId)
|
||||
}
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AdResult.Failure(createAdException("显示失败: ${e.message}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报广告收益数据(使用真实收益值)
|
||||
* @param splashAd 开屏广告对象
|
||||
* @param adInfo 广告信息
|
||||
* @param placementId 广告位ID
|
||||
*/
|
||||
private fun reportAdRevenueWithValue(splashAd: TUSplashAd, adInfo: TUAdInfo, placementId: String) {
|
||||
// 创建广告收益数据
|
||||
val adRevenueData = AdRevenueData(
|
||||
revenue = RevenueInfo(
|
||||
value = adInfo.publisherRevenue ?: 0.0,
|
||||
currencyCode = "USD"
|
||||
),
|
||||
adRevenueNetwork = adInfo.networkName ?: "",
|
||||
adRevenueUnit = placementId,
|
||||
adRevenuePlacement = adInfo.placementId ?: "",
|
||||
adFormat = "Splash"
|
||||
)
|
||||
|
||||
// 上报收益数据(内部已处理初始化和异常)
|
||||
AdRevenueManager.reportAdRevenue(adRevenueData)
|
||||
AdLogger.d("TopOn开屏广告真实收益数据已上报,广告位ID: %s, 收益: %.8f USD", placementId, adInfo.publisherRevenue ?: 0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁广告
|
||||
*/
|
||||
fun destroyAd() {
|
||||
synchronized(adCachePool) {
|
||||
adCachePool.clear()
|
||||
}
|
||||
AdLogger.d("TopOn开屏广告已销毁")
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁控制器
|
||||
*/
|
||||
fun destroy() {
|
||||
destroyAd()
|
||||
AdLogger.d("TopOn开屏广告控制器已清理")
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建广告异常
|
||||
*/
|
||||
private fun createAdException(message: String, cause: Throwable? = null): AdException {
|
||||
return AdException(
|
||||
code = 0,
|
||||
message = message,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数据上报函数
|
||||
* @param eventName 事件名称
|
||||
* @param params 参数Map,会与基础参数合并
|
||||
*/
|
||||
private fun reportAdData(eventName: String, params: Map<String, Any>) {
|
||||
val data = mutableMapOf<String, Any>(
|
||||
"ad_platform" to "TopOn",
|
||||
"ad_format" to "Splash"
|
||||
)
|
||||
|
||||
// 直接合并传入的参数
|
||||
data.putAll(params)
|
||||
|
||||
if (eventName == "ad_impression") {
|
||||
DataReportManager.reportDataByName("ThinkingData", eventName, data)
|
||||
} else {
|
||||
DataReportManager.reportData(eventName, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
package com.remax.bill.ads.util
|
||||
|
||||
import android.os.Parcel
|
||||
import com.google.android.gms.ads.BaseAdView
|
||||
import com.google.android.gms.ads.admanager.AdManagerAdView
|
||||
import com.google.android.gms.ads.appopen.AppOpenAd
|
||||
import com.google.android.gms.ads.interstitial.InterstitialAd
|
||||
import com.google.android.gms.ads.nativead.NativeAd
|
||||
import com.google.android.gms.ads.rewarded.RewardedAd
|
||||
import com.google.android.gms.ads.AdValue
|
||||
import com.google.android.gms.common.internal.safeparcel.SafeParcelReader
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import java.lang.reflect.Field
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* AdMob相关的反射工具,统一获取广告的AdValue。
|
||||
*/
|
||||
object AdmobReflectionUtil {
|
||||
|
||||
private val ivStackV1 = arrayOf("zzc", "zzj", "zzf", "zzd", "zzae")
|
||||
private val ivStackV2 = arrayOf("zzc", "zza", "a", "a", "d", "d", "ae")
|
||||
|
||||
private val bannerStackV1 = arrayOf("zza", "zzj", "zzi", "zze", "zza", "zzk", "zzae")
|
||||
private val bannerStackV2 = arrayOf("zza", "zzj", "zza", "a", "a", "f", "a", "e", "ae")
|
||||
|
||||
private val spStackV1 = arrayOf("zzb", "zza", "zzc", "zza", "zzk", "zzae")
|
||||
private val spStackV2 = arrayOf("zzb", "zza", "a", "a", "a", "e", "ae")
|
||||
|
||||
private val rvStackV1 = arrayOf("zzb", "zzi", "zze", "zze", "zzae")
|
||||
private val rvStackV2 = arrayOf("zzb", "zza", "b", "g", "e", "ae")
|
||||
|
||||
private val nativeStackV1 = arrayOf("zza", "zzb", "zzf", "zzD", "zzb", "zzae")
|
||||
private val nativeStackV2 = arrayOf("zza", "zza", "b", "d", "c", "ae")
|
||||
|
||||
/**
|
||||
* 通过反射获取任意 AdMob 广告收益信息,当前支持 Banner、开屏、插页、激励、原生。
|
||||
* @param ad 广告对象
|
||||
* @return [AdValue],未获取到返回 null
|
||||
*/
|
||||
fun getRevenue(ad: Any?): AdValue? {
|
||||
if (ad == null) return null
|
||||
val stackList = when (ad) {
|
||||
is InterstitialAd -> listOf(ivStackV1, ivStackV2)
|
||||
is RewardedAd -> listOf(rvStackV1, rvStackV2)
|
||||
is NativeAd -> listOf(nativeStackV1, nativeStackV2)
|
||||
is BaseAdView, is AdManagerAdView -> listOf(bannerStackV1, bannerStackV2)
|
||||
is AppOpenAd -> listOf(spStackV1, spStackV2)
|
||||
else -> emptyList()
|
||||
}
|
||||
stackList.forEach { stack ->
|
||||
val leaf = traverse(ad, stack) ?: return@forEach
|
||||
parseLeaf(leaf)?.let { return it }
|
||||
}
|
||||
AdLogger.w("AdmobReflectionUtil", "未能通过反射解析到收益信息,ad=${ad::class.java.simpleName}")
|
||||
return null
|
||||
}
|
||||
|
||||
private fun traverse(target: Any, stack: Array<String>): Any? {
|
||||
var current: Any? = target
|
||||
stack.forEach { fieldName ->
|
||||
current = current.getValue(fieldName) ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun parseLeaf(leaf: Any): AdValue? {
|
||||
if (leaf is AdValue) return leaf
|
||||
parcelAdValue(leaf)?.let { return it }
|
||||
|
||||
val fallbackCurrency = leaf.getValue("c")?.toString()?.takeIf { it.isNotBlank() } ?: ""
|
||||
val fallbackPrice = leaf.getValue("d")?.toString()?.toDoubleOrNull()
|
||||
val fallbackValueMicros = fallbackPrice?.let { (it * 1_000_000.0).roundToLong() } ?: 0L
|
||||
return createAdValue(
|
||||
precision = AdValue.PrecisionType.UNKNOWN,
|
||||
currencyCode = fallbackCurrency,
|
||||
valueMicros = fallbackValueMicros
|
||||
)
|
||||
}
|
||||
|
||||
private fun Any?.getValue(fieldName: String): Any? {
|
||||
if (this == null) return null
|
||||
return try {
|
||||
var clazz: Class<*>? = this::class.java
|
||||
var field: Field? = null
|
||||
while (clazz != null) {
|
||||
try {
|
||||
field = clazz.getDeclaredField(fieldName).apply { isAccessible = true }
|
||||
break
|
||||
} catch (ignored: NoSuchFieldException) {
|
||||
clazz = clazz.superclass
|
||||
}
|
||||
}
|
||||
field?.get(this)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdmobReflectionUtil", "反射获取字段失败: $fieldName", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parcelAdValue(source: Any?): AdValue? {
|
||||
if (source == null) return null
|
||||
return try {
|
||||
val method = source::class.java.getDeclaredMethod("writeToParcel", Parcel::class.java, Int::class.java)
|
||||
method.isAccessible = true
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
method.invoke(source, parcel, 0)
|
||||
parcel.setDataPosition(0)
|
||||
val header = SafeParcelReader.validateObjectHeader(parcel)
|
||||
var precision = AdValue.PrecisionType.UNKNOWN
|
||||
var currency = ""
|
||||
var value = 0L
|
||||
while (parcel.dataPosition() < header) {
|
||||
val readHeader = SafeParcelReader.readHeader(parcel)
|
||||
when (SafeParcelReader.getFieldId(readHeader)) {
|
||||
2 -> precision = SafeParcelReader.readInt(parcel, readHeader)
|
||||
3 -> currency = SafeParcelReader.createString(parcel, readHeader)
|
||||
4 -> value = SafeParcelReader.readLong(parcel, readHeader)
|
||||
else -> SafeParcelReader.skipUnknownField(parcel, readHeader)
|
||||
}
|
||||
}
|
||||
SafeParcelReader.ensureAtEnd(parcel, header)
|
||||
createAdValue(
|
||||
precision = precision,
|
||||
currencyCode = currency,
|
||||
valueMicros = value
|
||||
)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdmobReflectionUtil", "通过Parcel解析AdValue失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAdValue(precision: Int, currencyCode: String, valueMicros: Long): AdValue? {
|
||||
return try {
|
||||
val constructor = AdValue::class.java.getDeclaredConstructor(
|
||||
Int::class.javaPrimitiveType,
|
||||
String::class.java,
|
||||
Long::class.javaPrimitiveType
|
||||
)
|
||||
constructor.isAccessible = true
|
||||
constructor.newInstance(precision, currencyCode, valueMicros)
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("AdmobReflectionUtil", "实例化AdValue失败", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.remax.bill.ads.util
|
||||
|
||||
import android.app.Activity
|
||||
import com.blankj.utilcode.util.ActivityUtils
|
||||
import com.bytedance.sdk.openadsdk.activity.TTAppOpenAdActivity
|
||||
import com.bytedance.sdk.openadsdk.activity.TTFullScreenExpressVideoActivity
|
||||
import com.bytedance.sdk.openadsdk.activity.TTRewardExpressVideoActivity
|
||||
import com.google.android.gms.ads.AdActivity
|
||||
import com.remax.bill.ui.FullScreenNativeAdActivity
|
||||
import com.remax.bill.ui.pangle.PangleFullScreenNativeAdActivity
|
||||
|
||||
object PositionGet {
|
||||
fun get(): String{
|
||||
val activityList: MutableList<Activity?> = ActivityUtils.getActivityList()
|
||||
for (activity in activityList) {
|
||||
if (activity == null || !ActivityUtils.isActivityAlive(activity)
|
||||
|| activity is AdActivity || activity is FullScreenNativeAdActivity
|
||||
|| activity is PangleFullScreenNativeAdActivity || activity is TTRewardExpressVideoActivity || activity is TTAppOpenAdActivity || activity is TTFullScreenExpressVideoActivity
|
||||
) {
|
||||
continue
|
||||
}
|
||||
return activity::class.simpleName.orEmpty()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package com.remax.bill.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import com.google.android.gms.ads.AdView
|
||||
import com.remax.bill.R
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
|
||||
/**
|
||||
* Banner广告UI视图组件
|
||||
* 封装Banner广告的布局创建和显示逻辑
|
||||
*/
|
||||
class BannerAdView {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BannerAdView"
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并绑定Banner广告视图到容器中
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param adView AdMob的AdView
|
||||
* @param onExpandCallback 展开状态变化回调(已弃用,传null即可)
|
||||
* @return 是否绑定成功
|
||||
*/
|
||||
fun bindBannerAdToContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
adView: AdView,
|
||||
onExpandCallback: ((Boolean) -> Unit)? = null
|
||||
): Boolean {
|
||||
return try {
|
||||
// 清空容器
|
||||
container.removeAllViews()
|
||||
|
||||
// 创建Banner广告容器布局
|
||||
val bannerContainer = createBannerContainerLayout(context)
|
||||
|
||||
// 将AdView添加到容器中
|
||||
val adContainer = bannerContainer.findViewById<FrameLayout>(com.remax.bill.R.id.fl_ad_container)
|
||||
adContainer.removeAllViews()
|
||||
adContainer.addView(adView)
|
||||
|
||||
// 添加到目标容器
|
||||
container.addView(bannerContainer)
|
||||
|
||||
AdLogger.d("Banner广告视图绑定成功")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Banner广告视图绑定失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Banner加载中视图
|
||||
*/
|
||||
fun createBannerLoadingView(context: Context): View {
|
||||
return LayoutInflater.from(context).inflate(R.layout.layout_banner_loading, null).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 创建Banner容器布局
|
||||
*/
|
||||
private fun createBannerContainerLayout(context: Context): View {
|
||||
return LayoutInflater.from(context).inflate(R.layout.layout_banner_container, null).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
fun reset() {
|
||||
// Banner广告重置,目前无需特殊处理
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package com.remax.bill.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.R
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdMobManager
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.FullScreenNativeAdController
|
||||
import com.remax.bill.ads.ext.AdShowExt
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* 全屏原生广告Activity
|
||||
* 展示全屏的原生广告内容,通常用于应用启动或重要操作前
|
||||
*/
|
||||
class FullScreenNativeAdActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FullScreenNativeAdActivity"
|
||||
|
||||
/**
|
||||
* 启动全屏原生广告Activity
|
||||
* @param activity 启动Activity
|
||||
* @return AdResult<Unit> 广告显示结果
|
||||
*/
|
||||
suspend fun start(activity: Activity, showInterstitial: Boolean = true): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
|
||||
val intent = Intent(activity, FullScreenNativeAdActivity::class.java)
|
||||
intent.putExtra("showInterstitial", showInterstitial)
|
||||
activity.startActivity(intent)
|
||||
activity.overridePendingTransition(
|
||||
android.R.anim.fade_in,
|
||||
android.R.anim.fade_out
|
||||
)
|
||||
|
||||
// 存储continuation以便在Activity中调用
|
||||
FullScreenNativeAdActivity.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
// 用于存储continuation的变量
|
||||
@Volatile
|
||||
private var continuation: kotlinx.coroutines.CancellableContinuation<AdResult<Unit>>? = null
|
||||
|
||||
/**
|
||||
* 设置结果并恢复continuation
|
||||
*/
|
||||
fun setResult(result: AdResult<Unit>) {
|
||||
continuation?.let { cont ->
|
||||
if (cont.isActive) {
|
||||
cont.resume(result)
|
||||
}
|
||||
}
|
||||
continuation = null
|
||||
}
|
||||
}
|
||||
|
||||
private val fullScreenNativeController = AdMobManager.Controllers.fullScreenNative
|
||||
private val interstitialController = AdMobManager.Controllers.interstitial
|
||||
private val isShowInterstitial: Boolean
|
||||
get() = intent.getBooleanExtra("showInterstitial", true)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
window.apply {
|
||||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
setContentView(R.layout.activity_full_screen_native_ad)
|
||||
loadAndShowFullScreenNativeAd()
|
||||
if (isShowInterstitial) {
|
||||
showInterstitialAdAndNavigate {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载并显示全屏原生广告
|
||||
*/
|
||||
private fun loadAndShowFullScreenNativeAd() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
when (val result = fullScreenNativeController.showAdInContainer(
|
||||
context = this@FullScreenNativeAdActivity,
|
||||
container = findViewById<ViewGroup>(R.id.adContainer),
|
||||
lifecycleOwner = this@FullScreenNativeAdActivity,
|
||||
adUnitId = BuildConfig.ADMOB_FULL_NATIVE_ID
|
||||
)) {
|
||||
is AdResult.Success -> {
|
||||
findViewById<View>(R.id.rl_top_buttons).apply {
|
||||
isVisible = true
|
||||
findViewById<View>(R.id.btn_close).setOnClickListener {
|
||||
FullScreenNativeAdController.getInstance().closeEvent( adUnitId = BuildConfig.ADMOB_FULL_NATIVE_ID)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
}
|
||||
AdLogger.d("全屏原生广告页面加载成功")
|
||||
// 广告加载成功,展示页面,等待用户关闭时回调结果
|
||||
// 不在这里设置结果,而是在页面关闭时设置
|
||||
}
|
||||
|
||||
is AdResult.Failure -> {
|
||||
// 广告加载失败,立即返回失败结果
|
||||
setResult(result)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
|
||||
AdResult.Loading -> {
|
||||
// 广告正在加载中,等待结果
|
||||
AdLogger.d("全屏原生广告正在加载中")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 异常情况,立即返回失败结果
|
||||
AdLogger.e("全屏原生广告页面加载失败:${e.message}")
|
||||
setResult(
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -2,
|
||||
message = "全屏原生广告加载异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInterstitialAdAndNavigate(call: () -> Unit) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// 直接显示广告(自动处理加载)
|
||||
when (val result = AdShowExt.showInterstitialAd(
|
||||
this@FullScreenNativeAdActivity, ignoreFullNative = true
|
||||
)) {
|
||||
is AdResult.Success -> {
|
||||
call.invoke()
|
||||
}
|
||||
|
||||
is AdResult.Failure -> {
|
||||
call.invoke()
|
||||
}
|
||||
|
||||
AdResult.Loading -> {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭广告并结束Activity
|
||||
*/
|
||||
private fun closeAdAndFinish() {
|
||||
// 如果还没有设置结果(说明是用户主动关闭),设置成功结果
|
||||
if (continuation != null) {
|
||||
setResult(AdResult.Success(Unit))
|
||||
}
|
||||
finish()
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// 如果Activity被销毁但还没有设置结果,设置失败结果
|
||||
if (continuation != null) {
|
||||
setResult(
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -3,
|
||||
message = "Activity被销毁"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// 禁用返回键,只能通过广告关闭按钮关闭
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.remax.bill.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.RatingBar
|
||||
import android.widget.TextView
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.gms.ads.nativead.MediaView
|
||||
import com.google.android.gms.ads.nativead.NativeAd
|
||||
import com.remax.bill.R
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 全屏原生广告UI视图组件
|
||||
* 封装全屏原生广告的布局创建、数据绑定和交互逻辑
|
||||
*/
|
||||
class FullScreenNativeAdView {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FullScreenNativeAdView"
|
||||
private const val AUTO_CLOSE_DELAY = 10000L // 10秒自动关闭
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并绑定全屏原生广告视图到容器中
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param nativeAd 原生广告数据
|
||||
* @param lifecycleOwner 生命周期所有者(用于倒计时)
|
||||
* @param onCloseCallback 关闭回调
|
||||
* @return 是否绑定成功
|
||||
*/
|
||||
fun bindFullScreenNativeAdToContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
nativeAd: NativeAd,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
): Boolean {
|
||||
return try {
|
||||
// 清空容器
|
||||
container.removeAllViews()
|
||||
|
||||
// 创建全屏原生广告布局
|
||||
val adView = createFullScreenNativeAdLayout(context)
|
||||
|
||||
// 绑定广告数据
|
||||
bindFullScreenNativeAdData(adView, nativeAd, lifecycleOwner)
|
||||
|
||||
// 添加到容器
|
||||
container.addView(adView)
|
||||
|
||||
AdLogger.d("全屏原生广告视图绑定成功")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("全屏原生广告视图绑定失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建全屏加载视图
|
||||
*/
|
||||
fun createFullScreenLoadingView(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
) {
|
||||
try {
|
||||
container.removeAllViews()
|
||||
|
||||
val loadingView = LayoutInflater.from(context)
|
||||
.inflate(R.layout.layout_fullscreen_loading, container, false)
|
||||
|
||||
|
||||
container.addView(loadingView)
|
||||
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("创建全屏加载视图失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建全屏原生广告布局
|
||||
*/
|
||||
private fun createFullScreenNativeAdLayout(context: Context): com.google.android.gms.ads.nativead.NativeAdView {
|
||||
return LayoutInflater.from(context).inflate(R.layout.layout_fullscreen_native_ad, null) as com.google.android.gms.ads.nativead.NativeAdView
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定全屏原生广告数据到视图
|
||||
*/
|
||||
private fun bindFullScreenNativeAdData(
|
||||
adView: com.google.android.gms.ads.nativead.NativeAdView,
|
||||
nativeAd: NativeAd,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
) {
|
||||
try {
|
||||
val titleView = adView.findViewById<TextView>(R.id.tv_ad_title)
|
||||
val descView = adView.findViewById<TextView>(R.id.tv_ad_description)
|
||||
val ctaButton = adView.findViewById<TextView>(R.id.btn_ad_cta)
|
||||
val iconView = adView.findViewById<ImageView>(R.id.iv_ad_icon)
|
||||
val mediaView = adView.findViewById<MediaView>(R.id.mv_ad_media)
|
||||
|
||||
// 设置广告标题
|
||||
titleView?.text = nativeAd.headline ?: "Test Google Ads"
|
||||
|
||||
// 设置广告描述
|
||||
descView?.text = nativeAd.body ?: "Test Google Ads"
|
||||
|
||||
// 设置CTA按钮
|
||||
ctaButton?.text = nativeAd.callToAction ?: "Open"
|
||||
|
||||
|
||||
// 设置图标
|
||||
nativeAd.icon?.let { icon ->
|
||||
iconView?.setImageDrawable(icon.drawable)
|
||||
iconView?.visibility = View.VISIBLE
|
||||
} ?: run {
|
||||
iconView?.setImageResource(android.R.drawable.ic_menu_info_details)
|
||||
iconView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
// 设置媒体内容(如果有)
|
||||
nativeAd.mediaContent?.let { mediaContent ->
|
||||
mediaView?.setMediaContent(mediaContent)
|
||||
mediaView?.visibility = View.VISIBLE
|
||||
} ?: run {
|
||||
mediaView?.visibility = View.GONE
|
||||
}
|
||||
|
||||
// 绑定 AdMob NativeAdView
|
||||
adView.headlineView = titleView
|
||||
adView.bodyView = descView
|
||||
adView.callToActionView = ctaButton
|
||||
adView.iconView = iconView
|
||||
adView.starRatingView = null
|
||||
adView.mediaView = mediaView
|
||||
adView.advertiserView = null
|
||||
adView.priceView = null
|
||||
adView.storeView = null
|
||||
|
||||
// 绑定广告数据
|
||||
adView.setNativeAd(nativeAd)
|
||||
|
||||
AdLogger.d("全屏原生广告数据绑定完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e( "绑定全屏原生广告数据失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.remax.bill.ui
|
||||
|
||||
/**
|
||||
* 原生广告样式枚举
|
||||
* 定义不同的原生广告布局样式
|
||||
*/
|
||||
enum class NativeAdStyle(
|
||||
val layoutResId: Int,
|
||||
val description: String
|
||||
) {
|
||||
/**
|
||||
* 标准样式:水平布局,图标+标题+描述+按钮
|
||||
*/
|
||||
STANDARD(
|
||||
layoutResId = com.remax.bill.R.layout.layout_native_ads,
|
||||
description = "normal"
|
||||
),
|
||||
|
||||
/**
|
||||
* 卡片样式:垂直布局,更适合大尺寸展示
|
||||
*/
|
||||
CARD(
|
||||
layoutResId = com.remax.bill.R.layout.layout_native_ad_card,
|
||||
description = "card"
|
||||
),
|
||||
|
||||
CARD_2(
|
||||
layoutResId = com.remax.bill.R.layout.layout_native_ad_card2,
|
||||
description = "card2"
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
package com.remax.bill.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RatingBar
|
||||
import android.widget.TextView
|
||||
import com.google.android.gms.ads.nativead.NativeAd
|
||||
import com.remax.bill.R
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
|
||||
/**
|
||||
* 原生广告UI视图组件
|
||||
* 封装原生广告的布局创建和数据绑定逻辑
|
||||
*/
|
||||
class NativeAdView {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NativeAdView"
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并绑定原生广告视图到容器中
|
||||
* @param context 上下文
|
||||
* @param container 目标容器
|
||||
* @param nativeAd 原生广告数据
|
||||
* @param style 广告样式,默认为标准样式
|
||||
* @return 是否绑定成功
|
||||
*/
|
||||
fun bindNativeAdToContainer(
|
||||
context: Context,
|
||||
container: ViewGroup,
|
||||
nativeAd: NativeAd,
|
||||
style: NativeAdStyle = NativeAdStyle.STANDARD
|
||||
): Boolean {
|
||||
return try {
|
||||
// 清空容器
|
||||
container.removeAllViews()
|
||||
|
||||
// 创建原生广告布局
|
||||
val adView = createNativeAdLayout(context, style)
|
||||
|
||||
// 绑定广告数据
|
||||
bindNativeAdData(style, adView, nativeAd)
|
||||
|
||||
// 添加到容器
|
||||
container.addView(adView)
|
||||
|
||||
AdLogger.d("原生广告视图绑定成功")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("原生广告视图绑定失败", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建原生广告布局
|
||||
*/
|
||||
private fun createNativeAdLayout(
|
||||
context: Context,
|
||||
style: NativeAdStyle
|
||||
): com.google.android.gms.ads.nativead.NativeAdView {
|
||||
return LayoutInflater.from(context)
|
||||
.inflate(style.layoutResId, null) as com.google.android.gms.ads.nativead.NativeAdView
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定原生广告数据到视图
|
||||
*/
|
||||
private fun bindNativeAdData(
|
||||
style: NativeAdStyle,
|
||||
adView: com.google.android.gms.ads.nativead.NativeAdView,
|
||||
nativeAd: NativeAd
|
||||
) {
|
||||
try {
|
||||
val titleView = adView.findViewById<TextView>(R.id.tv_ad_title)
|
||||
val ctaButton = adView.findViewById<TextView>(R.id.btn_ad_cta)
|
||||
val iconView = adView.findViewById<ImageView>(R.id.iv_ad_icon)
|
||||
val ratingLayout = adView.findViewById<LinearLayout>(R.id.startLL)
|
||||
val descView = adView.findViewById<TextView>(R.id.tv_ad_description)
|
||||
|
||||
// 设置广告标题
|
||||
titleView?.text = nativeAd.headline ?: "Test Google Ads"
|
||||
|
||||
// 设置CTA按钮
|
||||
ctaButton?.text = nativeAd.callToAction ?: "INSTALL"
|
||||
|
||||
// 设置广告描述
|
||||
descView?.text = nativeAd.body
|
||||
|
||||
// 不需要手动设置点击监听器,AdMob会自动处理
|
||||
|
||||
// 绑定AdMob NativeAdView
|
||||
adView.headlineView = titleView
|
||||
adView.callToActionView = ctaButton
|
||||
adView.iconView = iconView
|
||||
adView.bodyView = descView
|
||||
adView.starRatingView = ratingLayout
|
||||
adView.advertiserView = null
|
||||
adView.mediaView = null
|
||||
adView.priceView = null
|
||||
adView.storeView = null
|
||||
|
||||
// 绑定广告数据
|
||||
adView.setNativeAd(nativeAd)
|
||||
|
||||
// 设置评分(如果有)
|
||||
nativeAd.starRating?.let { rating ->
|
||||
// 显示评分布局
|
||||
ratingLayout?.visibility = View.VISIBLE
|
||||
// 根据评分动态设置星级图标
|
||||
updateStarRating(style, ratingLayout, rating.toFloat())
|
||||
} ?: run {
|
||||
// 如果没有评分,显示默认评分(4.5分)
|
||||
ratingLayout?.visibility = View.VISIBLE
|
||||
updateStarRating(style, ratingLayout, 4.5f)
|
||||
}
|
||||
|
||||
// 设置图标
|
||||
nativeAd.icon?.let { icon ->
|
||||
iconView?.setImageDrawable(icon.drawable)
|
||||
iconView?.visibility = View.VISIBLE
|
||||
} ?: run {
|
||||
iconView?.setImageResource(android.R.drawable.ic_menu_info_details)
|
||||
iconView?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
AdLogger.d("原生广告数据绑定完成")
|
||||
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("绑定原生广告数据失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新星级评分显示
|
||||
* @param ratingLayout 评分布局容器
|
||||
* @param rating 评分值 (0.0-5.0)
|
||||
*/
|
||||
private fun updateStarRating(style: NativeAdStyle, ratingLayout: LinearLayout?, rating: Float) {
|
||||
ratingLayout?.let { layout ->
|
||||
// 确保评分在有效范围内
|
||||
val validRating = rating.coerceIn(0f, 5f)
|
||||
|
||||
// 获取所有星级图标
|
||||
val starViews = mutableListOf<ImageView>()
|
||||
for (i in 0 until layout.childCount) {
|
||||
val child = layout.getChildAt(i)
|
||||
if (child is ImageView) {
|
||||
starViews.add(child)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了星级图标,则更新它们
|
||||
if (starViews.isNotEmpty()) {
|
||||
updateStarIcons(style, starViews, validRating)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新星级图标显示
|
||||
* @param starViews 星级图标列表
|
||||
* @param rating 评分值
|
||||
*/
|
||||
private fun updateStarIcons(style: NativeAdStyle, starViews: List<ImageView>, rating: Float) {
|
||||
val fullStars = rating.toInt() // 满星数量
|
||||
val hasHalfStar = rating % 1 >= 0.5f // 是否有半星
|
||||
|
||||
starViews.forEachIndexed { index, imageView ->
|
||||
when {
|
||||
index < fullStars -> {
|
||||
// 满星
|
||||
imageView.setImageResource(if (style == NativeAdStyle.CARD_2) R.drawable.ic_star_filled_green else R.drawable.ic_star_filled)
|
||||
imageView.visibility = android.view.View.VISIBLE
|
||||
}
|
||||
|
||||
index == fullStars && hasHalfStar -> {
|
||||
// 半星
|
||||
imageView.setImageResource((if (style == NativeAdStyle.CARD_2) R.drawable.ic_star_half_green else R.drawable.ic_star_half))
|
||||
imageView.visibility = android.view.View.VISIBLE
|
||||
}
|
||||
|
||||
else -> {
|
||||
// 空星 - 使用半星图标但设置为透明
|
||||
imageView.setImageResource(R.drawable.ic_star_empty)
|
||||
imageView.visibility = android.view.View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载中的占位视图
|
||||
*/
|
||||
fun createLoadingView(context: Context): View {
|
||||
return LayoutInflater.from(context).inflate(R.layout.layout_ad_loading, null).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建加载失败的占位视图
|
||||
*/
|
||||
fun createErrorView(context: Context, errorMessage: String? = null): View {
|
||||
return TextView(context).apply {
|
||||
text = errorMessage ?: "广告加载失败"
|
||||
textSize = 12f
|
||||
setTextColor(0xFF999999.toInt())
|
||||
gravity = android.view.Gravity.CENTER
|
||||
setPadding(16, 16, 16, 16)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
package com.remax.bill.ui.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import com.remax.bill.R
|
||||
|
||||
/**
|
||||
* 全屏Loading弹框
|
||||
* 提供show和hide伴生对象函数
|
||||
* show时不允许关闭,只能通过hide关闭
|
||||
* 完全防止点击穿透
|
||||
*/
|
||||
class ADLoadingDialog private constructor(context: Context) : Dialog(context) {
|
||||
|
||||
init {
|
||||
setupDialog()
|
||||
}
|
||||
|
||||
private fun setupDialog() {
|
||||
// 设置无标题栏
|
||||
requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||
|
||||
// 设置全屏
|
||||
window?.let { window ->
|
||||
// 设置背景透明
|
||||
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
|
||||
// 设置布局参数 - 完全防止点击穿透
|
||||
val layoutParams = WindowManager.LayoutParams().apply {
|
||||
width = WindowManager.LayoutParams.MATCH_PARENT
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
// 设置背景半透明
|
||||
dimAmount = 0.5f
|
||||
// 不设置任何特殊标志,让Dialog正常拦截所有触摸事件
|
||||
flags = 0
|
||||
}
|
||||
window.attributes = layoutParams
|
||||
}
|
||||
|
||||
// 设置布局
|
||||
setContentView(R.layout.layout_ad_dialog_loading)
|
||||
|
||||
// 设置不可取消
|
||||
setCancelable(false)
|
||||
setCanceledOnTouchOutside(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var instance: ADLoadingDialog? = null
|
||||
|
||||
/**
|
||||
* 显示Loading弹框
|
||||
* @param context 上下文
|
||||
*/
|
||||
fun show(context: Context) {
|
||||
hide() // 先隐藏之前的实例
|
||||
|
||||
instance = ADLoadingDialog(context)
|
||||
instance?.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏Loading弹框
|
||||
*/
|
||||
fun hide() {
|
||||
instance?.let { dialog ->
|
||||
if (dialog.isShowing) {
|
||||
runCatching {
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
instance = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在显示
|
||||
*/
|
||||
fun isShowing(): Boolean {
|
||||
return instance?.isShowing ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package com.remax.bill.ui.pangle
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.remax.bill.BuildConfig
|
||||
import com.remax.bill.R
|
||||
import com.remax.bill.ads.AdException
|
||||
import com.remax.bill.ads.AdResult
|
||||
import com.remax.bill.ads.ext.AdShowExt
|
||||
import com.remax.bill.ads.log.AdLogger
|
||||
import com.remax.bill.ads.pangle.PangleFullScreenNativeAdController
|
||||
import com.remax.bill.ads.pangle.PangleManager
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Pangle全屏原生广告展示页
|
||||
*/
|
||||
class PangleFullScreenNativeAdActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_SHOW_INTERSTITIAL = "showInterstitial"
|
||||
|
||||
suspend fun start(activity: Activity, showInterstitial: Boolean = true): AdResult<Unit> {
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val intent = Intent(activity, PangleFullScreenNativeAdActivity::class.java)
|
||||
intent.putExtra(EXTRA_SHOW_INTERSTITIAL, showInterstitial)
|
||||
activity.startActivity(intent)
|
||||
activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
this.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var continuation: kotlinx.coroutines.CancellableContinuation<AdResult<Unit>>? = null
|
||||
|
||||
fun setResult(result: AdResult<Unit>) {
|
||||
continuation?.let { cont ->
|
||||
if (cont.isActive) {
|
||||
cont.resume(result)
|
||||
}
|
||||
}
|
||||
continuation = null
|
||||
}
|
||||
}
|
||||
|
||||
private val fullScreenNativeController = PangleManager.Controllers.fullScreenNative
|
||||
private val interstitialController = PangleManager.Controllers.interstitial
|
||||
|
||||
private val shouldShowInterstitial: Boolean
|
||||
get() = intent.getBooleanExtra(EXTRA_SHOW_INTERSTITIAL, true)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
window.apply {
|
||||
addFlags(android.view.WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
|
||||
@Suppress("DEPRECATION")
|
||||
navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
setContentView(R.layout.activity_pangle_full_screen_native_ad)
|
||||
loadAndShowFullScreenNativeAd()
|
||||
if (shouldShowInterstitial) {
|
||||
showInterstitialAd {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAndShowFullScreenNativeAd() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
when (val result = fullScreenNativeController.showAdInContainer(
|
||||
context = this@PangleFullScreenNativeAdActivity,
|
||||
container = findViewById(R.id.adContainer),
|
||||
lifecycleOwner = this@PangleFullScreenNativeAdActivity,
|
||||
adUnitId = BuildConfig.PANGLE_FULL_NATIVE_ID
|
||||
)) {
|
||||
is AdResult.Success -> {
|
||||
findViewById<View>(R.id.rl_top_buttons)?.apply {
|
||||
isVisible = true
|
||||
findViewById<View>(R.id.btn_close)?.setOnClickListener {
|
||||
PangleFullScreenNativeAdController.getInstance().closeEvent(adUnitId = BuildConfig.PANGLE_FULL_NATIVE_ID)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
}
|
||||
AdLogger.d("Pangle全屏原生广告展示成功")
|
||||
}
|
||||
|
||||
is AdResult.Failure -> {
|
||||
setResult(result)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
|
||||
AdResult.Loading -> {
|
||||
AdLogger.d("Pangle全屏原生广告加载中")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AdLogger.e("Pangle全屏原生广告展示异常:${e.message}")
|
||||
setResult(
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -2,
|
||||
message = "Pangle全屏原生广告加载异常: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
)
|
||||
)
|
||||
closeAdAndFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showInterstitialAd(onFinished: () -> Unit) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
when (val result = AdShowExt.showInterstitialAd(
|
||||
activity = this@PangleFullScreenNativeAdActivity,
|
||||
ignoreFullNative = true
|
||||
)) {
|
||||
is AdResult.Success, is AdResult.Failure -> onFinished()
|
||||
AdResult.Loading -> Unit
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeAdAndFinish() {
|
||||
if (continuation != null) {
|
||||
setResult(AdResult.Success(Unit))
|
||||
}
|
||||
finish()
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (continuation != null) {
|
||||
setResult(
|
||||
AdResult.Failure(
|
||||
AdException(
|
||||
code = -3,
|
||||
message = "Activity被销毁"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
// 禁用返回键
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue