新增bill模块

This commit is contained in:
renhaoting 2025-12-17 13:41:41 +08:00
parent ba4c5708db
commit 96a3e641ce
152 changed files with 22558 additions and 4 deletions

View File

@ -70,7 +70,7 @@ dependencies {
implementation(project(":core:architecture"))
//api(project(":core:architecture-reflect"))
implementation(project(":core:network"))
implementation(project(":bill"))
implementation(project(":youtube:core"))
implementation(project(":youtube:custom-ui"))

49
app/config_debug.gradle Normal file
View File

@ -0,0 +1,49 @@
ext {
// AdMob配置
admob = [applicationId: "ca-app-pub-3940256099942544~3347511713", // IDAdMob应用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", // 广ID320x50
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
}

48
app/config_release.gradle Normal file
View File

@ -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 : "", // 广ID320x50
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"] //
}

1
base/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

85
base/build.gradle.kts Normal file
View File

@ -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
base/consumer-rules.pro Normal file
View File

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

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.remax.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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
package com.remax.base.report
/**
* 数据上报接口
* 提供统一的数据上报功能
*/
interface DataReporter {
/**
* 获取上报器名称
* @return 上报器名称
*/
fun getName(): String
/**
* 上报数据
* @param eventName 事件名称
* @param data 数据Mapkey为参数名value为参数值
*/
fun reportData(eventName: String, data: Map<String, Any>)
/**
* 设置公共参数
* @param params 公共参数Mapkey为参数名value为参数值
*/
fun setCommonParams(params: Map<String, Any>)
/**
* 设置用户参数
* @param params 用户参数Mapkey为参数名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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">base</string>
</resources>

View File

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

View File

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

1
bill/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

143
bill/build.gradle.kts Normal file
View File

@ -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
bill/consumer-rules.pro Normal file
View File

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

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.remax.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)
}
}

View File

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

View File

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

View File

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

View File

@ -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("所有广告控制器已清理")
}
}

View File

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

View File

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

View File

@ -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广告控制器已清理")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
/**
* 开屏广告竞价控制器
* 同时加载 AdMobPangle 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
}
}

View File

@ -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广告竞价控制器
* 同时加载 AdMobPangle 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
}
}

View File

@ -0,0 +1,108 @@
package com.remax.bill.ads.bidding
/**
* 比价平台控制器
* 用于控制各广告类型参与比价的平台
*
* 当前策略
* - 插页广告AdMobPangleTopOn 都参与比价
* - Banner广告AdMobTopOn 参与比价
* - 开屏广告AdMobTopOn 参与比价
* - 原生广告AdMobTopOn 参与比价
* - 全屏原生广告AdMobTopOn 参与比价
*/
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)
}

View File

@ -0,0 +1,5 @@
package com.remax.bill.ads.bidding
enum class BiddingWinner {
ADMOB, PANGLE, TOPON
}

View File

@ -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
/**
* 全屏原生广告竞价控制器
* 同时加载 AdMobPangle 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
}
}

View File

@ -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
/**
* 插页广告竞价控制器
* 同时加载 AdMobPangle 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
}
}

View File

@ -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
/**
* 原生广告竞价控制器
* 同时加载 AdMobPangle 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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广告控制器已清理")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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广告控制器已清理")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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广告重置目前无需特殊处理
}
}

View File

@ -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() {
// 禁用返回键,只能通过广告关闭按钮关闭
}
}

View File

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

View File

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

View File

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

View File

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

View File

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