新增bill模块
This commit is contained in:
parent
ba4c5708db
commit
96a3e641ce
|
|
@ -70,7 +70,7 @@ dependencies {
|
||||||
implementation(project(":core:architecture"))
|
implementation(project(":core:architecture"))
|
||||||
//api(project(":core:architecture-reflect"))
|
//api(project(":core:architecture-reflect"))
|
||||||
implementation(project(":core:network"))
|
implementation(project(":core:network"))
|
||||||
|
implementation(project(":bill"))
|
||||||
implementation(project(":youtube:core"))
|
implementation(project(":youtube:core"))
|
||||||
implementation(project(":youtube:custom-ui"))
|
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