diff --git a/app/build.gradle b/app/build.gradle index 97910e5..3860370 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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")) diff --git a/app/config_debug.gradle b/app/config_debug.gradle new file mode 100644 index 0000000..1a4f8b6 --- /dev/null +++ b/app/config_debug.gradle @@ -0,0 +1,49 @@ +ext { + // AdMob配置 + admob = [applicationId: "ca-app-pub-3940256099942544~3347511713", // 测试应用ID,请替换为实际的AdMob应用ID + adUnitIds : [banner : "ca-app-pub-3940256099942544/9214589741", // 横幅广告测试ID + interstitial: "ca-app-pub-3940256099942544/1033173712", // 插页广告测试ID + splash : "ca-app-pub-3940256099942544/9257395921", // 开屏广告测试ID + native : "ca-app-pub-3940256099942544/2247696110", // 原生广告测试ID + full_native : "ca-app-pub-3940256099942544/2247696110", // 全屏原生广告测试ID + rewarded : "ca-app-pub-3940256099942544/5224354917" // 激励广告测试ID + ]] + + // Pangle配置 + pangle = [applicationId: "8025677", // Pangle测试应用ID + adUnitIds : [splash : "890000078", // 开屏广告测试ID(竖屏) + banner : "980099802", // 横幅广告测试ID(320x50) + interstitial: "980088188", // 插页广告测试ID(竖屏) + native : "980088216", // 原生广告测试ID + full_native : "980088216", // 全屏原生广告测试ID + rewarded : "980088192" // 激励视频测试ID(竖屏) + ]] + + // TopOn配置 + topon = [applicationId: "a5aa1f9deda26d", // TopOn 应用 ID(需替换为实际值) + appKey : "4f7b9ac17decb9babec83aac078742c7", // TopOn 应用密钥(需替换为实际值) + adUnitIds : [interstitial: "b5baca53984692", // 插页广告位 ID(需替换为实际值) + rewarded : "b5b449fb3d89d7", // 激励广告位 ID + native : "b5aa1fa2cae775", // 原生广告位 ID(需替换为实际值) + splash : "b5f73fe0c5db29", // 开屏广告位 ID(需替换为实际值) + full_native : "b5aa1fa501d9f6", // 全屏原生广告位 ID(需替换为实际值) + banner : "b5baca4f74c3d8"] // 横幅广告位 ID(需替换为实际值) + ] + + // 应用版本配置 + app = [applicationId: "com.remax.video.recovery", + compileSdk : 35, + minSdk : libs.versions.minSdk.get().toInteger(), + targetSdk : 35, + versionCode : 1, + versionName : "1.0.2"] + + url = [privacyUrl: "https://www.google.com", + teamUrl : "https://www.google.com",] + + // 统计归因配置 + analytics = [adjustAppToken: "h6qax9dxv7cw", // Adjust App Token + thinkingDataAppId: "61b7ef0186b74b76b301b67184b7b48b", // 数数 SDK APP ID + thinkingDataServerUrl: "https://xray.alifmd.com", // 数数上报域名 + defaultUserChannel: "paid"] // 默认用户渠道,internal默认paid +} \ No newline at end of file diff --git a/app/config_release.gradle b/app/config_release.gradle new file mode 100644 index 0000000..e434022 --- /dev/null +++ b/app/config_release.gradle @@ -0,0 +1,48 @@ +ext { + // AdMob配置 - Play 市场版本 + admob = [applicationId: "ca-app-pub-1350364678590045~1984631821", // Play市场AdMob应用ID + adUnitIds : [banner : "ca-app-pub-1350364678590045/8582717815", // 横幅广告正式ID + interstitial: "ca-app-pub-1350364678590045/5193588407", // 插页广告正式ID + splash : "ca-app-pub-1350364678590045/5568653612", // 开屏广告正式ID + native : "ca-app-pub-1350364678590045/8003245260", // 原生广告正式ID + full_native : "ca-app-pub-1350364678590045/1219233112" // 原生广告正式ID + ]] + + // Pangle配置 + pangle = [applicationId: "8750604", // Pangle测试应用ID + adUnitIds : [splash : "", // 开屏广告测试ID(竖屏) + banner : "", // 横幅广告测试ID(320x50) + interstitial: "982604080", // 插页广告测试ID(竖屏) + native : "", // 原生广告测试ID + full_native : "", // 全屏原生广告测试ID + rewarded : "" // 激励视频测试ID(竖屏) + ]] + + // TopOn配置 + topon = [applicationId: "h1gq3c2vm973ma", // TopOn 应用 ID(需替换为实际值) + appKey : "a96bffecc1c32132c6984a3e97512b5f9", // TopOn 应用密钥(需替换为实际值) + adUnitIds : [interstitial: "n1gq3c2vobnfr5", // 插页广告位 ID(需替换为实际值) + rewarded : "", // 激励广告位 ID + native : "n1gq3c2vpbobmj", // 原生广告位 ID(需替换为实际值) + splash : "n1gq3c2vppegpc", // 开屏广告位 ID(需替换为实际值) + full_native : "n1gq3c2volk4ad", // 全屏原生广告位 ID(需替换为实际值) + banner : "n1gq3c2vq30fsn"] // 横幅广告位 ID(需替换为实际值) + ] + + // 应用版本配置 - Play 市场版本 + app = [applicationId: "com.files.restore.recovery.tool.deleted.document.photo.video.audio.app.mobile.scan.utility", + compileSdk : 35, + minSdk : libs.versions.minSdk.get().toInteger(), + targetSdk : 35, + versionCode : 3, + versionName : "1.0.2"] + + url = [privacyUrl: "https://alifmd.com/privacy.html", + teamUrl : "https://alifmd.com/privacy.html",] + + // 统计归因配置 + analytics = [adjustAppToken: "h6qax9dxv7cw", // Adjust App Token + thinkingDataAppId: "61b7ef0186b74b76b301b67184b7b48b", // 数数 SDK APP ID + thinkingDataServerUrl: "https://xray.alifmd.com",// 数数上报域名 + defaultUserChannel: "natural"] //默认渠道 +} \ No newline at end of file diff --git a/base/.gitignore b/base/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/base/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/base/build.gradle.kts b/base/build.gradle.kts new file mode 100644 index 0000000..69c79b5 --- /dev/null +++ b/base/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/base/consumer-rules.pro b/base/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/base/proguard-rules.pro b/base/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/base/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/base/src/androidTest/java/com/remax/base/ExampleInstrumentedTest.kt b/base/src/androidTest/java/com/remax/base/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f39e1b8 --- /dev/null +++ b/base/src/androidTest/java/com/remax/base/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/base/src/main/AndroidManifest.xml b/base/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8696e30 --- /dev/null +++ b/base/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/ads/AdRevenueReporter.kt b/base/src/main/java/com/remax/base/ads/AdRevenueReporter.kt new file mode 100644 index 0000000..55d148a --- /dev/null +++ b/base/src/main/java/com/remax/base/ads/AdRevenueReporter.kt @@ -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() + + /** + * 设置广告收益上报器实现 + * @param reporter 具体的上报器实现 + */ + fun setReporter(reporter: AdRevenueReporter) { + reporters.clear() + reporters.add(reporter) + } + + /** + * 设置多个广告收益上报器实现 + * @param reporters 上报器实现集合 + */ + fun setReporters(reporters: Collection) { + 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() +} diff --git a/base/src/main/java/com/remax/base/controller/SystemPageNavigationController.kt b/base/src/main/java/com/remax/base/controller/SystemPageNavigationController.kt new file mode 100644 index 0000000..007f2d5 --- /dev/null +++ b/base/src/main/java/com/remax/base/controller/SystemPageNavigationController.kt @@ -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 + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/controller/UserChannelController.kt b/base/src/main/java/com/remax/base/controller/UserChannelController.kt new file mode 100644 index 0000000..2f11597 --- /dev/null +++ b/base/src/main/java/com/remax/base/controller/UserChannelController.kt @@ -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() + + /** + * 获取默认渠道值 + * 优先使用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 + } +} diff --git a/base/src/main/java/com/remax/base/ext/KvDelegate.kt b/base/src/main/java/com/remax/base/ext/KvDelegate.kt new file mode 100644 index 0000000..5aabd53 --- /dev/null +++ b/base/src/main/java/com/remax/base/ext/KvDelegate.kt @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 = setOf()) : + ReadWriteProperty?> { + override fun getValue(thisRef: Any?, property: KProperty<*>): Set? { + return kv.getStringSet(key, def) + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: Set?) { + kvEditor.putStringSet(key, value).apply() + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/ext/NotificationPermissionExt.kt b/base/src/main/java/com/remax/base/ext/NotificationPermissionExt.kt new file mode 100644 index 0000000..8305860 --- /dev/null +++ b/base/src/main/java/com/remax/base/ext/NotificationPermissionExt.kt @@ -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()) + } +} diff --git a/base/src/main/java/com/remax/base/ext/StoragePermissionExt.kt b/base/src/main/java/com/remax/base/ext/StoragePermissionExt.kt new file mode 100644 index 0000000..8e3a7ec --- /dev/null +++ b/base/src/main/java/com/remax/base/ext/StoragePermissionExt.kt @@ -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 }) + } + } + +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/ext/ViewBindUtil.kt b/base/src/main/java/com/remax/base/ext/ViewBindUtil.kt new file mode 100644 index 0000000..ce3f274 --- /dev/null +++ b/base/src/main/java/com/remax/base/ext/ViewBindUtil.kt @@ -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 AppCompatActivity.inflateBindingWithGeneric(layoutInflater: LayoutInflater): VB = + withGenericBindingClass(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 Fragment.inflateBindingWithGeneric( + layoutInflater: LayoutInflater, + parent: ViewGroup?, + attachToParent: Boolean +): VB { + return inflateBindingWithGeneric(null, layoutInflater, parent, attachToParent) +} + +fun Fragment.inflateBindingWithGeneric( + targetClass: Class? = null, + layoutInflater: LayoutInflater, + parent: ViewGroup?, + attachToParent: Boolean +): VB = + withGenericBindingClass(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 withGenericBindingClass( + any: Any, + targetClass: Class? = null, + block: (Class) -> 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) + } 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 ViewGroup.viewBinding() = + ViewBindingDelegate(T::class.java, this) + +class ViewBindingDelegate( + private val bindingClass: Class, + val view: ViewGroup +) : ReadOnlyProperty { + 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") + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/ext/WindowInsetsExt.kt b/base/src/main/java/com/remax/base/ext/WindowInsetsExt.kt new file mode 100644 index 0000000..a5e3bf6 --- /dev/null +++ b/base/src/main/java/com/remax/base/ext/WindowInsetsExt.kt @@ -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 { + topMargin += systemBars.top + } + } +} + +/** + * 为View添加底部边距,避免被导航栏遮挡 + * 用于确保内容不延伸到导航栏下方 + */ +fun View.appendNavigationBarMarginBottom() { + WindowInsetsCache.initIfNeeded(this) { systemBars -> + updateLayoutParams { + 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 + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/log/BaseLogger.kt b/base/src/main/java/com/remax/base/log/BaseLogger.kt new file mode 100644 index 0000000..e4c59b2 --- /dev/null +++ b/base/src/main/java/com/remax/base/log/BaseLogger.kt @@ -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)) + } + } +} diff --git a/base/src/main/java/com/remax/base/provider/BaseModuleProvider.kt b/base/src/main/java/com/remax/base/provider/BaseModuleProvider.kt new file mode 100644 index 0000000..bc51385 --- /dev/null +++ b/base/src/main/java/com/remax/base/provider/BaseModuleProvider.kt @@ -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?, + selection: String?, + selectionArgs: Array?, + 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?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} diff --git a/base/src/main/java/com/remax/base/report/DataReporter.kt b/base/src/main/java/com/remax/base/report/DataReporter.kt new file mode 100644 index 0000000..1686993 --- /dev/null +++ b/base/src/main/java/com/remax/base/report/DataReporter.kt @@ -0,0 +1,134 @@ +package com.remax.base.report + +/** + * 数据上报接口 + * 提供统一的数据上报功能 + */ +interface DataReporter { + /** + * 获取上报器名称 + * @return 上报器名称 + */ + fun getName(): String + + /** + * 上报数据 + * @param eventName 事件名称 + * @param data 数据Map,key为参数名,value为参数值 + */ + fun reportData(eventName: String, data: Map) + + /** + * 设置公共参数 + * @param params 公共参数Map,key为参数名,value为参数值 + */ + fun setCommonParams(params: Map) + + /** + * 设置用户参数 + * @param params 用户参数Map,key为参数名,value为参数值 + */ + fun setUserParams(params: Map) +} + +/** + * 数据上报管理器 + * 管理数据上报器的注入和调用 + */ +object DataReportManager { + private var reporters: MutableList = mutableListOf() + + /** + * 设置数据上报器集合 + * @param reporters 数据上报器实现集合 + */ + fun setReporters(reporters: Collection) { + this.reporters.clear() + this.reporters.addAll(reporters) + } + + /** + * 设置公共参数 + * @param params 公共参数Map + */ + fun setCommonParams(params: Map) { + // 同时设置所有上报器的公共参数 + reporters.forEach { reporter -> + try { + reporter.setCommonParams(params) + } catch (e: Exception) { + // 单个上报器失败不影响其他上报器 + e.printStackTrace() + } + } + } + + /** + * 设置用户参数 + * @param params 用户参数Map + */ + fun setUserParams(params: Map) { + // 同时设置所有上报器的用户参数 + reporters.forEach { reporter -> + try { + reporter.setUserParams(params) + } catch (e: Exception) { + // 单个上报器失败不影响其他上报器 + e.printStackTrace() + } + } + } + + /** + * 上报数据(自动合并公共参数和用户参数) + * @param eventName 事件名称 + * @param data 数据Map + */ + fun reportData(eventName: String, data: Map) { + 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) { + 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() + } +} diff --git a/base/src/main/java/com/remax/base/temlate/BaseAct.kt b/base/src/main/java/com/remax/base/temlate/BaseAct.kt new file mode 100644 index 0000000..d756c29 --- /dev/null +++ b/base/src/main/java/com/remax/base/temlate/BaseAct.kt @@ -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)) + + } + + +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/temlate/BaseBindAct.kt b/base/src/main/java/com/remax/base/temlate/BaseBindAct.kt new file mode 100644 index 0000000..65110fb --- /dev/null +++ b/base/src/main/java/com/remax/base/temlate/BaseBindAct.kt @@ -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 : BaseAct() { + + override fun xGetIdXml(): Int = 0 + + lateinit var selfBindView: VB + + override fun prepareBindView(): View? { + selfBindView = inflateBindingWithGeneric(layoutInflater) + return selfBindView.root + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/temlate/BaseBindFrag.kt b/base/src/main/java/com/remax/base/temlate/BaseBindFrag.kt new file mode 100644 index 0000000..27e3881 --- /dev/null +++ b/base/src/main/java/com/remax/base/temlate/BaseBindFrag.kt @@ -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 : 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 + } + +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/temlate/BaseFrag.kt b/base/src/main/java/com/remax/base/temlate/BaseFrag.kt new file mode 100644 index 0000000..e21d6e8 --- /dev/null +++ b/base/src/main/java/com/remax/base/temlate/BaseFrag.kt @@ -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() { + } + +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/utils/ActivityLauncher.kt b/base/src/main/java/com/remax/base/utils/ActivityLauncher.kt new file mode 100644 index 0000000..e2aba29 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/ActivityLauncher.kt @@ -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>? = null + private val permissionLauncher = + activityResultCaller.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result: Map -> + permissionCallback?.onActivityResult(result) + } + + fun launch( + permissionArray: Array, + permissionCallback: ActivityResultCallback>? + ) { + this.permissionCallback = permissionCallback + permissionLauncher.launch(permissionArray) + } + + //endregion + + //region intent跳转 + private var activityResultCallback: ActivityResultCallback? = null + private val intentLauncher = + activityResultCaller.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult: ActivityResult -> + activityResultCallback?.onActivityResult(activityResult) + } + + /** + * it.resultCode == Activity.RESULT_OK + */ + fun launch( + intent: Intent, + activityResultCallback: ActivityResultCallback? = null + ) { + this.activityResultCallback = activityResultCallback + intentLauncher.launch(intent) + } + //endregion + + + //region saf +// private var safResultCallback: ActivityResultCallback? = null +// private val safLauncher = +// activityResultCaller.registerForActivityResult( +// ActivityResultContracts.OpenDocument(), +// ) { uri -> +// safResultCallback?.onActivityResult(uri) +// } +// +// fun launch(array: Array, safResultCallback: ActivityResultCallback?) { +// this.safResultCallback = safResultCallback +// safLauncher.launch(array) +// } + //end region + + +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/utils/ContextProvider.kt b/base/src/main/java/com/remax/base/utils/ContextProvider.kt new file mode 100644 index 0000000..4533fc9 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/ContextProvider.kt @@ -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 + } +} diff --git a/base/src/main/java/com/remax/base/utils/FileScanner.kt b/base/src/main/java/com/remax/base/utils/FileScanner.kt new file mode 100644 index 0000000..6480159 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/FileScanner.kt @@ -0,0 +1,1139 @@ +package com.remax.base.utils + +import android.content.Context +import android.os.Environment +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.util.Locale + +/** + * 文件扫描工具 + * 提供文件过滤和扫描功能 + */ +object FileScanner { + + private const val TAG = "FileScanner" + const val COPY_DIR = "Remax File Recovery" + const val COPY_DIR_PHOTO = "re_photo" + const val COPY_DIR_VIDEO = "re_video" + const val COPY_DIR_DOCUMENT = "re_document" + const val COPY_DIR_AUDIO = "re_audio" + const val COPY_DIR_OTHER = "re_other" + + /** + * 日志控制开关 + */ + private var isLogEnabled = true + + /** + * 设置是否启用日志 + * @param enabled true 启用日志,false 禁用日志 + */ + fun setLogEnabled(enabled: Boolean) { + isLogEnabled = enabled + } + + /** + * 检查是否启用日志 + * @return true 如果启用日志,false 如果禁用日志 + */ + fun isLogEnabled(): Boolean = isLogEnabled + + /** + * 日志输出方法 + */ + fun log(level: String, message: String, throwable: Throwable? = null) { + if (!isLogEnabled) return + + when (level) { + "D" -> Log.d(TAG, message, throwable) + "W" -> Log.w(TAG, message, throwable) + "E" -> Log.e(TAG, message, throwable) + "V" -> Log.v(TAG, message, throwable) + } + } + + /** + * 文件过滤器接口 + */ + interface FileFilter { + /** + * 判断文件是否符合过滤条件 + * @param file 待检查的文件 + * @return true 如果文件符合条件,false 如果不符合 + */ + fun accept(file: File): Boolean + + /** + * 获取过滤器名称 + * @return 过滤器名称 + */ + fun getFilterName(): String + + /** + * 获取要扫描的目录列表 + * @return 要扫描的目录列表,如果返回空列表则扫描整个外部存储 + */ + fun getScanDirectories(): List = emptyList() + + /** + * 获取要排除的目录列表 + * @return 要排除的目录列表,默认为空列表 + */ + fun getExcludeDirectories(): List = emptyList() + } + + /** + * 基础文件过滤器 + * 提供通用的文件类型检查方法 + */ + abstract class BaseFileFilter : FileFilter { + + // 排除掉已恢复 + override fun getExcludeDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + File(externalStorage, COPY_DIR) + ) + } + + } + + + /** + * 截图文件过滤器 + * 过滤出截图文件(Screenshot、截图等) + */ + class ScreenshotFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + + // 首先检查是否为图片文件 + if (!file.isImage()) { + return false + } + + val fileName = file.name.lowercase() + val filePath = file.absolutePath.lowercase() + + // 检查文件名是否包含截图相关关键词 + val screenshotKeywords = listOf( + "screenshot", "截图", "截屏", "screen", "capture", "shot" + ) + + // 检查路径是否包含截图相关目录 + val screenshotPaths = listOf( + "screenshot", "screenshots", "截图", "截屏", "captures" + ) + + // 检查文件名 + val hasScreenshotKeyword = screenshotKeywords.any { keyword -> + fileName.contains(keyword) + } + + // 检查路径 + val hasScreenshotPath = screenshotPaths.any { path -> + filePath.contains(path) + } + + return hasScreenshotKeyword || hasScreenshotPath + } + + override fun getFilterName(): String = "Screenshot Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + File(externalStorage, "DCIM"), + File(externalStorage, "Pictures"), + File(externalStorage, "Screenshots"), + File(externalStorage, "Download") + ) + } + } + + /** + * 图片文件过滤器 + * 过滤出所有图片文件 + */ + open class ImageFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + return file.isImage() + } + + override fun getFilterName(): String = "Image Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + externalStorage + ) + } + } + + /** + * 视频文件过滤器 + * 过滤出所有视频文件 + */ + open class VideoFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + return file.isVideo() + } + + override fun getFilterName(): String = "Video Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + externalStorage + ) + } + } + + /** + * 文档文件过滤器 + * 过滤出所有文档文件(PDF、Office文档等) + */ + open class DocumentFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + return file.isDocument() + } + + override fun getFilterName(): String = "Document Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + externalStorage + ) + } + } + + /** + * 音频文件过滤器 + * 过滤出所有音频文件(MP3、WAV、FLAC等) + */ + open class AudioFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + return file.isAudio() + } + + override fun getFilterName(): String = "Audio Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + externalStorage + ) + } + } + + /** + * 大文件过滤器 + * 过滤出文件大小大于等于指定最小值的文件 + */ + class LargeFileFilter( + private val minSizeBytes: Long = 10 * 1024 * 1024L // 默认10MB + ) : BaseFileFilter() { + + override fun accept(file: File): Boolean { + if (!file.isFile) return false + + // 检查文件大小是否大于等于最小值 + return try { + file.length() >= minSizeBytes + } catch (e: Exception) { + log("W", "检查文件大小时发生错误: ${file.absolutePath}", e) + false + } + } + + override fun getFilterName(): String = "Large File Filter (≥${formatFileSize(minSizeBytes)})" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf( + externalStorage + ) + } + + /** + * 格式化文件大小为可读格式 + * @param bytes 字节数 + * @return 格式化后的文件大小字符串 + */ + private fun formatFileSize(bytes: Long): String { + return when { + bytes >= 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024 * 1024)}GB" + bytes >= 1024 * 1024 -> "${bytes / (1024 * 1024)}MB" + bytes >= 1024 -> "${bytes / 1024}KB" + else -> "${bytes}B" + } + } + } + + /** + * 垃圾清理过滤器 + * 过滤出所有类型的垃圾文件: + * 1. 垃圾文件:.thumbnails目录下的图片、.crash、.anr、.tombstone等 + * 2. 安装包:APK文件 + * 3. 临时文件:.cache、.tmp、.temp、.dex、.odex + * 4. 日志文件:.log、.log.txt、.out、.err + */ + class JunkCleanFilter : BaseFileFilter() { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + return file.isJunkFile() || file.isApkFile() || file.isTempFile() || file.isLogFile() + } + + override fun getFilterName(): String = "Junk Clean Filter" + + override fun getScanDirectories(): List { + val externalStorage = Environment.getExternalStorageDirectory() + return listOf(externalStorage) + } + } + + /** + * 自定义文件过滤器 + * 根据文件扩展名过滤 + */ + class CustomFilter( + private val extensions: List, + private val scanDirectories: List = emptyList(), + private val excludeDirectories: List = emptyList() + ) : FileFilter { + override fun accept(file: File): Boolean { + if (!file.isFile) return false + + val extension = file.extension.lowercase() + return extension in extensions + } + + override fun getFilterName(): String = "Custom Filter (${extensions.joinToString(", ")})" + + override fun getScanDirectories(): List = scanDirectories + + override fun getExcludeDirectories(): List = excludeDirectories + } + + /** + * 扫描结果数据类 + */ + data class ScanResult( + val files: List, val totalCount: Int, val scanTime: Long, val filterName: String + ) + + /** + * 扫描外部存储中的文件 + * @param context 上下文 + * @param filter 文件过滤器 + * @param onFileScanned 文件扫描回调,参数为当前扫描的文件和是否匹配过滤器 + * @param onProgress 扫描进度回调,参数为进度百分比(0.0-1.0) + * @return 扫描结果 + */ + suspend fun scanExternalStorage( + filter: FileFilter, + onFileScanned: ((file: File, matchFilter: Boolean) -> Unit)? = null, + onProgress: ((progress: Float) -> Unit)? = null + ): ScanResult = withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + val files = mutableListOf() + + try { + // 获取要扫描的目录列表 + val scanDirectories = filter.getScanDirectories() + val excludeDirectories = filter.getExcludeDirectories() + + val directoriesToScan = if (scanDirectories.isEmpty()) { + // 如果没有指定目录,扫描整个外部存储 + listOf(Environment.getExternalStorageDirectory()) + } else { + scanDirectories + } + + // 检查目录是否可访问,并排除指定的目录 + val validDirectories = directoriesToScan.filter { dir -> + dir.exists() && dir.canRead() && !isExcludedDirectory(dir, excludeDirectories) + } + + if (validDirectories.isEmpty()) { + log("W", "没有可访问的目录") + return@withContext ScanResult(emptyList(), 0, 0, filter.getFilterName()) + } + + log( + "D", + "开始扫描,目录数: ${validDirectories.size}, 排除目录数: ${excludeDirectories.size}" + ) + + // 检查是否需要进度跟踪 + val needProgressTracking = onProgress != null || onFileScanned != null + + if (needProgressTracking) { + // 需要进度跟踪的完整扫描流程 + // 第一步:计算所有文件的总数 + log("D", "开始计算文件总数...") + var totalFiles = 0 + validDirectories.forEach { directory -> + totalFiles += countFiles(directory, excludeDirectories) + } + log("D", "文件总数计算完成: $totalFiles") + + if (totalFiles == 0) { + log("W", "没有找到任何文件") + return@withContext ScanResult(emptyList(), 0, 0, filter.getFilterName()) + } + + // 第二步:扫描文件并更新进度 + var scannedFiles = 0 + validDirectories.forEach { directory -> + scannedFiles = scanDirectoryWithProgress( + directory, + filter, + files, + onFileScanned, + excludeDirectories, + totalFiles, + scannedFiles + ) { newScannedFiles -> + val progress = newScannedFiles.toFloat() / totalFiles.toFloat() + onProgress?.invoke(progress) + } + } + } else { + // 快速扫描模式 - 不需要进度跟踪 + log("D", "使用快速扫描模式") + validDirectories.forEach { directory -> + scanDirectoryFast(directory, filter, files, excludeDirectories) + } + } + + val scanTime = System.currentTimeMillis() - startTime + log("D", "扫描完成,找到 ${files.size} 个文件,耗时 ${scanTime}ms") + + ScanResult(files, files.size, scanTime, filter.getFilterName()) + + } catch (e: Exception) { + log("E", "扫描文件时发生错误", e) + val scanTime = System.currentTimeMillis() - startTime + ScanResult(files, 0, scanTime, filter.getFilterName()) + } + } + + /** + * 检查目录是否在排除列表中 + * @param directory 要检查的目录 + * @param excludeDirectories 排除目录列表 + * @return true 如果目录应该被排除,false 如果不应该被排除 + */ + private fun isExcludedDirectory(directory: File, excludeDirectories: List): Boolean { + return excludeDirectories.any { excludeDir -> + directory.absolutePath == excludeDir.absolutePath || + directory.absolutePath.startsWith(excludeDir.absolutePath + File.separator) + } + } + + /** + * 扫描指定目录 + * @param directory 要扫描的目录 + * @param filter 文件过滤器 + * @param resultFiles 结果文件列表 + * @param onFileScanned 文件扫描回调 + * @param excludeDirectories 排除目录列表 + */ + private fun scanDirectory( + directory: File, + filter: FileFilter, + resultFiles: MutableList, + onFileScanned: ((file: File, matchFilter: Boolean) -> Unit)?, + excludeDirectories: List + ): Int { + return scanDirectoryWithProgress( + directory, + filter, + resultFiles, + onFileScanned, + excludeDirectories, + 0, + 0 + ) { } + } + + /** + * 快速扫描指定目录(无回调) + * @param directory 要扫描的目录 + * @param filter 文件过滤器 + * @param resultFiles 结果文件列表 + * @param excludeDirectories 排除目录列表 + */ + private fun scanDirectoryFast( + directory: File, + filter: FileFilter, + resultFiles: MutableList, + excludeDirectories: List + ) { + try { + if (!directory.exists() || !directory.canRead()) { + log("W", "目录不存在或无法读取: ${directory.absolutePath}") + return + } + + val files = directory.listFiles() + if (files == null) { + log("W", "无法列出目录内容: ${directory.absolutePath}") + return + } + + for (file in files) { + try { + if (file.isFile) { + // 直接检查文件是否符合过滤条件,不调用回调 + try { + if (filter.accept(file)) { + resultFiles.add(file) + log("V", "找到匹配文件: ${file.absolutePath}") + } + } catch (e: Exception) { + log("W", "过滤器检查文件时发生错误: ${file.absolutePath}", e) + } + } else if (file.isDirectory) { + // 检查是否为排除目录 + if (!isExcludedDirectory(file, excludeDirectories)) { + // 递归扫描子目录 + scanDirectoryFast(file, filter, resultFiles, excludeDirectories) + } else { + log("D", "跳过排除目录: ${file.absolutePath}") + } + } + } catch (e: Exception) { + log("W", "处理文件时发生错误: ${file.absolutePath}", e) + } + } + } catch (e: Exception) { + log("E", "扫描目录时发生错误: ${directory.absolutePath}", e) + } + } + + /** + * 扫描指定目录(带进度跟踪) + * @param directory 要扫描的目录 + * @param filter 文件过滤器 + * @param resultFiles 结果文件列表 + * @param onFileScanned 文件扫描回调 + * @param excludeDirectories 排除目录列表 + * @param totalFiles 总文件数 + * @param currentScannedFiles 当前已扫描的文件数 + * @param onProgressUpdate 进度更新回调 + */ + private fun scanDirectoryWithProgress( + directory: File, + filter: FileFilter, + resultFiles: MutableList, + onFileScanned: ((file: File, matchFilter: Boolean) -> Unit)?, + excludeDirectories: List, + totalFiles: Int, + currentScannedFiles: Int, + onProgressUpdate: (Int) -> Unit + ): Int { + var scannedFiles = currentScannedFiles + + try { + if (!directory.exists() || !directory.canRead()) { + log("W", "目录不存在或无法读取: ${directory.absolutePath}") + return scannedFiles + } + + val files = directory.listFiles() + if (files == null) { + log("W", "无法列出目录内容: ${directory.absolutePath}") + return scannedFiles + } + + for (file in files) { + try { + if (file.isFile) { + // 检查文件是否符合过滤条件(只调用一次) + val isMatchFilter = try { + filter.accept(file) + } catch (e: Exception) { + log("W", "过滤器检查文件时发生错误: ${file.absolutePath}", e) + false + } + + // 调用文件扫描回调 + onFileScanned?.invoke(file, isMatchFilter) + + // 更新已扫描文件数 + scannedFiles++ + onProgressUpdate(scannedFiles) + + // 如果文件匹配过滤器,添加到结果列表 + if (isMatchFilter) { + resultFiles.add(file) + log("V", "找到匹配文件: ${file.absolutePath}") + } + } else if (file.isDirectory) { + // 检查是否为排除目录 + if (!isExcludedDirectory(file, excludeDirectories)) { + // 递归扫描子目录 + scannedFiles = scanDirectoryWithProgress( + file, + filter, + resultFiles, + onFileScanned, + excludeDirectories, + totalFiles, + scannedFiles, + onProgressUpdate + ) + } else { + log("D", "跳过排除目录: ${file.absolutePath}") + } + } + } catch (e: Exception) { + log("W", "处理文件时发生错误: ${file.absolutePath}", e) + } + } + } catch (e: Exception) { + log("E", "扫描目录时发生错误: ${directory.absolutePath}", e) + } + + return scannedFiles + } + + /** + * 统计目录中的文件总数(排除指定目录) + * @param directory 要统计的目录 + * @param excludeDirectories 排除目录列表 + * @return 文件总数 + */ + private fun countFiles(directory: File, excludeDirectories: List = emptyList()): Int { + var count = 0 + try { + if (!directory.exists() || !directory.canRead()) { + return 0 + } + + val files = directory.listFiles() + if (files == null) { + return 0 + } + + for (file in files) { + if (file.isFile) { + count++ + } else if (file.isDirectory) { + // 检查是否为排除目录 + if (!isExcludedDirectory(file, excludeDirectories)) { + count += countFiles(file, excludeDirectories) + } + } + } + } catch (e: Exception) { + log("W", "统计文件数时发生错误: ${directory.absolutePath}", e) + } + return count + } + + +} + +/** + * File扩展函数 - 文档类型检测 + */ +fun File.isPdfFile(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查PDF文件头 (0-4字节: %PDF) + val pdfHeader = byteArrayOf(0x25.toByte(), 0x50.toByte(), 0x44.toByte(), 0x46.toByte()) + headerBytes.copyOfRange(0, 4).contentEquals(pdfHeader) + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +fun File.isOfficeDocument(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查Office文档文件头 (0-2字节: PK) - 新版Office文档 + val pkHeader = byteArrayOf(0x50.toByte(), 0x4B.toByte()) + if (headerBytes.copyOfRange(0, 2).contentEquals(pkHeader)) { + return isNewOfficeDocument(randomAccessFile) + } + + // 检查旧版Office文档 (DOC, XLS, PPT) + isLegacyOfficeDocument(headerBytes) + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +fun File.isNewOfficeDocument(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查Office文档文件头 (0-2字节: PK) + val pkHeader = byteArrayOf(0x50.toByte(), 0x4B.toByte()) + if (headerBytes.copyOfRange(0, 2).contentEquals(pkHeader)) { + return isNewOfficeDocument(randomAccessFile) + } + false + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +fun File.isLegacyOfficeDocument(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + isLegacyOfficeDocument(headerBytes) + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +/** + * 私有辅助函数 + */ +private fun isNewOfficeDocument(randomAccessFile: RandomAccessFile): Boolean { + return try { + // 重新定位到文件开头 + randomAccessFile.seek(0) + + // 读取ZIP文件头 + val zipHeader = ByteArray(4) + randomAccessFile.read(zipHeader) + + // 检查ZIP文件头 + val pkHeader = byteArrayOf(0x50.toByte(), 0x4B.toByte()) + if (!zipHeader.copyOfRange(0, 2).contentEquals(pkHeader)) { + return false + } + + // 检查Office文档特有的文件 + // Word文档: [Content_Types].xml, _rels/, docProps/ + // Excel文档: [Content_Types].xml, _rels/, docProps/, xl/ + // PowerPoint文档: [Content_Types].xml, _rels/, docProps/, ppt/ + val officeFiles = listOf( + "[Content_Types].xml", + "_rels/", + "docProps/", + "xl/", // Excel特有 + "ppt/", // PowerPoint特有 + "word/" // Word特有 + ) + + // 读取文件内容进行搜索 + val buffer = ByteArray(8192) // 8KB缓冲区 + randomAccessFile.read(buffer) + val content = String(buffer, Charsets.UTF_8) + + // 检查是否包含Office文档特有的文件 + val foundOfficeFiles = officeFiles.count { content.contains(it) } + + // 如果找到至少2个Office特有文件,认为是Office文档 + return foundOfficeFiles >= 2 + } catch (e: Exception) { + false + } +} + +private fun isLegacyOfficeDocument(headerBytes: ByteArray): Boolean { + // 检查旧版Office文档的文件头 + // 所有旧版Office文档都使用相同的OLE文件头 + // DOC, XLS, PPT 文件都以 D0 CF 11 E0 A1 B1 1A E1 开头 + val oleHeader = byteArrayOf(0xD0.toByte(), 0xCF.toByte(), 0x11.toByte(), 0xE0.toByte()) + return headerBytes.copyOfRange(0, 4).contentEquals(oleHeader) +} + + +/** + * 计算文件集合的总大小(返回字节数) + * @param files 文件集合 + * @return Long 总字节数 + */ +fun calculateTotalSizeInBytes(files: Collection): Long { + var totalBytes = 0L + + files.forEach { file -> + if (file.exists() && file.isFile) { + try { + totalBytes += file.length() + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + return totalBytes +} + +/** + * File扩展函数 - 音频文件检测 + */ +fun File.isAudio(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查MP3文件头 (0-3字节: ID3 或 0-2字节: 0xFF 0xFB/0xFA/0xF2/0xF3) + val id3Header = byteArrayOf(0x49.toByte(), 0x44.toByte(), 0x33.toByte()) + if (headerBytes.copyOfRange(0, 3).contentEquals(id3Header)) { + return true + } + + // 检查MP3同步字节 + val syncBytes = listOf( + byteArrayOf(0xFF.toByte(), 0xFB.toByte()), + byteArrayOf(0xFF.toByte(), 0xFA.toByte()), + byteArrayOf(0xFF.toByte(), 0xF2.toByte()), + byteArrayOf(0xFF.toByte(), 0xF3.toByte()) + ) + if (syncBytes.any { sync -> + headerBytes.copyOfRange(0, 2).contentEquals(sync) + }) { + return true + } + + // 检查WAV文件头 (0-4字节: RIFF, 8-12字节: WAVE) + val riffBytes = headerBytes.copyOfRange(0, 4) + val riffString = String(riffBytes) + if (riffString == "RIFF") { + val waveBytes = headerBytes.copyOfRange(8, 12) + val waveString = String(waveBytes) + if (waveString == "WAVE") { + return true + } + } + + // 检查FLAC文件头 (0-4字节: fLaC) + val flacHeader = + byteArrayOf(0x66.toByte(), 0x4C.toByte(), 0x61.toByte(), 0x43.toByte()) + if (headerBytes.copyOfRange(0, 4).contentEquals(flacHeader)) { + return true + } + + return false + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +/** + * File扩展函数 - 视频文件检测 + */ +fun File.isVideo(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查MP4文件头 (4-8字节: ftyp) + val ftypBytes = headerBytes.copyOfRange(4, 8) + val ftypString = String(ftypBytes) + if (ftypString == "ftyp") { + // 检查支持的格式 (8-12字节) + val formatBytes = headerBytes.copyOfRange(8, 12) + val formatString = String(formatBytes) + val supportedFormats = + listOf("mp42", "mp41", "isom", "iso2", "avc1", "3gp4", "M4V ", "M4A ") + if (supportedFormats.any { format -> formatString.startsWith(format) }) { + return true + } + } + + // 检查AVI文件头 (0-4字节: RIFF, 8-12字节: AVI ) + val riffBytes = headerBytes.copyOfRange(0, 4) + val riffString = String(riffBytes) + if (riffString == "RIFF") { + val aviBytes = headerBytes.copyOfRange(8, 12) + val aviString = String(aviBytes) + if (aviString == "AVI ") { + return true + } + } + + // 检查MKV文件头 (0-4字节: EBML) + val ebmlBytes = headerBytes.copyOfRange(0, 4) + val ebmlString = String(ebmlBytes) + if (ebmlString == "EBML") { + return true + } + + return false + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +/** + * File扩展函数 - 图片文件检测 + */ +fun File.isImage(): Boolean { + return try { + if (!exists() || !isFile) return false + + // 检查文件大小 + if (length() < 12) return false + + val randomAccessFile = RandomAccessFile(this, "r") + try { + // 读取前12字节 + val headerBytes = ByteArray(12) + randomAccessFile.read(headerBytes) + + // 检查PNG文件头 (0-4字节: 89504E47) + val pngHeader = + byteArrayOf(0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte()) + if (headerBytes.copyOfRange(0, 4).contentEquals(pngHeader)) { + return true + } + + // 检查JPEG文件头 (0-2字节: FFD8) + val jpegHeader = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) + if (headerBytes.copyOfRange(0, 2).contentEquals(jpegHeader)) { + return true + } + + // 检查WebP文件头 (8-12字节: WEBP) + val webpHeader = + byteArrayOf(0x57.toByte(), 0x45.toByte(), 0x42.toByte(), 0x50.toByte()) + if (headerBytes.copyOfRange(8, 12).contentEquals(webpHeader)) { + return true + } + + return false + } finally { + randomAccessFile.close() + } + } catch (e: Exception) { + false + } +} + +/** + * File扩展函数 - 文档文件检测 + */ +fun File.isDocument(): Boolean { + return isPdfFile() || isOfficeDocument() +} + +/** + * File扩展函数 - 垃圾文件检测 + */ +fun File.isJunkFile(): Boolean { + if (!exists() || !isFile) return false + + val fileName = name.lowercase() + val extension = extension.lowercase() + + // 检查垃圾文件扩展名 + val junkExtensions = listOf(".crash", ".anr", ".tombstone") + if (extension in junkExtensions) { + return true + } + + // 检查.thumbnails目录下的图片文件 + if (parentFile?.name?.lowercase() == ".thumbnails") { + val imageExtensions = listOf("jpg", "jpeg", "png") + if (extension in imageExtensions) { + return true + } + } + + return false +} + +/** + * File扩展函数 - 安装包文件检测 + */ +fun File.isApkFile(): Boolean { + if (!exists() || !isFile) return false + return extension.lowercase() == "apk" +} + +/** + * File扩展函数 - 临时文件检测 + */ +fun File.isTempFile(): Boolean { + if (!exists() || !isFile) return false + + val extension = extension.lowercase() + val tempExtensions = listOf("cache", "tmp", "temp", "dex", "odex") + + return extension in tempExtensions +} + +/** + * File扩展函数 - 日志文件检测 + */ +fun File.isLogFile(): Boolean { + if (!exists() || !isFile) return false + + val fileName = name.lowercase() + val extension = extension.lowercase() + + // 检查日志文件扩展名 + val logExtensions = listOf("log", "out", "err") + if (extension in logExtensions) { + return true + } + + // 检查.log.txt格式 + if (fileName.endsWith(".log.txt")) { + return true + } + + return false +} + +/** + * Long扩展函数 - 友好的文件大小显示 + * @return 格式化后的文件大小字符串 + */ +fun Long.getFriendlySize(): String { + return when { + this >= 1_073_741_824L -> { + val gb = this.toDouble() / 1_073_741_824.0 + val formatted = String.format(Locale.ENGLISH, "%.1f", gb) + val result = if (formatted.endsWith(".0")) { + formatted.substring(0, formatted.length - 2) + } else { + formatted + } + "${result}GB" + } + this >= 1_048_576L -> { + val mb = this.toDouble() / 1_048_576.0 + val formatted = String.format(Locale.ENGLISH, "%.1f", mb) + val result = if (formatted.endsWith(".0")) { + formatted.substring(0, formatted.length - 2) + } else { + formatted + } + "${result}MB" + } + this >= 1024L -> { + val kb = this.toDouble() / 1024.0 + val formatted = String.format(Locale.ENGLISH, "%.1f", kb) + val result = if (formatted.endsWith(".0")) { + formatted.substring(0, formatted.length - 2) + } else { + formatted + } + "${result}KB" + } + else -> "0KB" + } +} + +/** + * String扩展函数 - 解析大小字符串,分离数字和单位 + * @param sizeString 格式化的文件大小字符串,如 "1.2MB", "500KB", "1024B" + * @return Pair<数字部分, 单位部分> + */ +fun String.parseSizeAndUnit(): Pair { + return when { + this.endsWith("GB") -> { + val size = this.substring(0, this.length - 2) + Pair(size, "GB") + } + this.endsWith("MB") -> { + val size = this.substring(0, this.length - 2) + Pair(size, "MB") + } + this.endsWith("KB") -> { + val size = this.substring(0, this.length - 2) + Pair(size, "KB") + } + this.endsWith("B") -> { + val size = this.substring(0, this.length - 1) + Pair(size, "B") + } + else -> { + // 如果格式不匹配,默认显示原字符串 + Pair(this, "") + } + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/utils/ImageLoader.kt b/base/src/main/java/com/remax/base/utils/ImageLoader.kt new file mode 100644 index 0000000..6cf8420 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/ImageLoader.kt @@ -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) + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/utils/LanguageController.kt b/base/src/main/java/com/remax/base/utils/LanguageController.kt new file mode 100644 index 0000000..12e3120 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/LanguageController.kt @@ -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 = 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 = 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() + } + + +} + diff --git a/base/src/main/java/com/remax/base/utils/LottieUtils.kt b/base/src/main/java/com/remax/base/utils/LottieUtils.kt new file mode 100644 index 0000000..1391390 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/LottieUtils.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/utils/RemoteConfigManager.kt b/base/src/main/java/com/remax/base/utils/RemoteConfigManager.kt new file mode 100644 index 0000000..573bde8 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/RemoteConfigManager.kt @@ -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) + } + } + } +} diff --git a/base/src/main/java/com/remax/base/utils/RoundedCornersTransformation.kt b/base/src/main/java/com/remax/base/utils/RoundedCornersTransformation.kt new file mode 100644 index 0000000..7eb1080 --- /dev/null +++ b/base/src/main/java/com/remax/base/utils/RoundedCornersTransformation.kt @@ -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) + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/view/GridSpacingItemDecoration.kt b/base/src/main/java/com/remax/base/view/GridSpacingItemDecoration.kt new file mode 100644 index 0000000..7a40aef --- /dev/null +++ b/base/src/main/java/com/remax/base/view/GridSpacingItemDecoration.kt @@ -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() + + 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 + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/view/LinearDividerItemDecoration.kt b/base/src/main/java/com/remax/base/view/LinearDividerItemDecoration.kt new file mode 100644 index 0000000..44ac6ea --- /dev/null +++ b/base/src/main/java/com/remax/base/view/LinearDividerItemDecoration.kt @@ -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() + 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 + } +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/view/RecyclerViewExtensions.kt b/base/src/main/java/com/remax/base/view/RecyclerViewExtensions.kt new file mode 100644 index 0000000..e450de5 --- /dev/null +++ b/base/src/main/java/com/remax/base/view/RecyclerViewExtensions.kt @@ -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 + ) +} \ No newline at end of file diff --git a/base/src/main/java/com/remax/base/view/RemaxRoundedFrameLayout.kt b/base/src/main/java/com/remax/base/view/RemaxRoundedFrameLayout.kt new file mode 100644 index 0000000..571a524 --- /dev/null +++ b/base/src/main/java/com/remax/base/view/RemaxRoundedFrameLayout.kt @@ -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() + } + + +} \ No newline at end of file diff --git a/base/src/main/res/values-night/themes.xml b/base/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..17a54dd --- /dev/null +++ b/base/src/main/res/values-night/themes.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/base/src/main/res/values/attrs.xml b/base/src/main/res/values/attrs.xml new file mode 100644 index 0000000..65a9932 --- /dev/null +++ b/base/src/main/res/values/attrs.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/base/src/main/res/values/colors.xml b/base/src/main/res/values/colors.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/base/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml new file mode 100644 index 0000000..7542515 --- /dev/null +++ b/base/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + base + \ No newline at end of file diff --git a/base/src/main/res/values/themes.xml b/base/src/main/res/values/themes.xml new file mode 100644 index 0000000..17a54dd --- /dev/null +++ b/base/src/main/res/values/themes.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/base/src/test/java/com/remax/base/ExampleUnitTest.kt b/base/src/test/java/com/remax/base/ExampleUnitTest.kt new file mode 100644 index 0000000..476ca7e --- /dev/null +++ b/base/src/test/java/com/remax/base/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/bill/.gitignore b/bill/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/bill/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bill/build.gradle.kts b/bill/build.gradle.kts new file mode 100644 index 0000000..b2fcd4f --- /dev/null +++ b/bill/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/bill/consumer-rules.pro b/bill/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/bill/proguard-rules.pro b/bill/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/bill/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/bill/src/androidTest/java/com/remax/bill/ExampleInstrumentedTest.kt b/bill/src/androidTest/java/com/remax/bill/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..2470f4d --- /dev/null +++ b/bill/src/androidTest/java/com/remax/bill/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/bill/src/main/AndroidManifest.xml b/bill/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3ec7001 --- /dev/null +++ b/bill/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/assets/ad_config.json b/bill/src/main/assets/ad_config.json new file mode 100644 index 0000000..df06fdb --- /dev/null +++ b/bill/src/main/assets/ad_config.json @@ -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 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/AdActivityInterceptor.kt b/bill/src/main/java/com/remax/bill/ads/AdActivityInterceptor.kt new file mode 100644 index 0000000..805c9b3 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/AdActivityInterceptor.kt @@ -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") + } + +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/AdMobManager.kt b/bill/src/main/java/com/remax/bill/ads/AdMobManager.kt new file mode 100644 index 0000000..ca46b88 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/AdMobManager.kt @@ -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.Loading) + val initializationState: StateFlow> = _initializationState.asStateFlow() + + private var isInitialized = false + + /** + * 初始化 AdMob SDK + */ + suspend fun initialize(context: Context): AdResult { + 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 { + 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("所有广告控制器已清理") + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/AdResult.kt b/bill/src/main/java/com/remax/bill/ads/AdResult.kt new file mode 100644 index 0000000..6956b19 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/AdResult.kt @@ -0,0 +1,43 @@ +package com.remax.bill.ads + +/** + * 广告操作结果 + */ +sealed class AdResult { + /** + * 成功 + */ + data class Success(val data: T) : AdResult() + + /** + * 失败 + */ + data class Failure(val error: AdException) : AdResult() + + /** + * 加载中 + */ + object Loading : AdResult() +} + +/** + * 广告异常信息 + */ +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 + } +} + + \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/AppOpenAdController.kt b/bill/src/main/java/com/remax/bill/ads/AppOpenAdController.kt new file mode 100644 index 0000000..054fe04 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/AppOpenAdController.kt @@ -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() + 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 { + 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 { + 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 { + // 累积触发广告展示次数统计 + 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 { + 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) { + val data = mutableMapOf( + "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) + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/BannerAdController.kt b/bill/src/main/java/com/remax/bill/ads/BannerAdController.kt new file mode 100644 index 0000000..277b4ef --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/BannerAdController.kt @@ -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() + 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.Loading) + val loadingState: StateFlow> = _loadingState.asStateFlow() + + private val _adExpiredState = MutableStateFlow(false) + val adExpiredState: StateFlow = _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) { + val data = mutableMapOf( + "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 { + 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 { + 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 { + 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广告控制器已清理") + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/FullScreenNativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/FullScreenNativeAdController.kt new file mode 100644 index 0000000..467eae8 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/FullScreenNativeAdController.kt @@ -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() + 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.Loading) + val loadingState: StateFlow> = _loadingState.asStateFlow() + + private val _showingState = MutableStateFlow?>(null) + val showingState: StateFlow?> = _showingState.asStateFlow() + + private val _adExpiredState = MutableStateFlow(false) + val adExpiredState: StateFlow = _adExpiredState.asStateFlow() + + var nativeAds :NativeAd ?=null + + /** + * 预加载全屏原生广告(可选,用于提前准备) + * @param context 上下文 + * @param adUnitId 广告位ID,如果为空则使用默认ID + */ + suspend fun preloadAd(context: Context, adUnitId: String? = null): AdResult { + 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 { + 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 广告显示结果 + */ + suspend fun showAdInContainer( + context: Context, + container: ViewGroup, + lifecycleOwner: LifecycleOwner, + adUnitId: String? = null + ): AdResult { + 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 { + 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) { + val data = mutableMapOf( + "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 { + 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 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/InterstitialAdController.kt b/bill/src/main/java/com/remax/bill/ads/InterstitialAdController.kt new file mode 100644 index 0000000..95258be --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/InterstitialAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/NativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/NativeAdController.kt new file mode 100644 index 0000000..c3a61f6 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/NativeAdController.kt @@ -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() + 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.Loading) + val loadingState: StateFlow> = _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 { + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 + ) + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/PreloadController.kt b/bill/src/main/java/com/remax/bill/ads/PreloadController.kt new file mode 100644 index 0000000..b125c7a --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/PreloadController.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/RewardedAdController.kt b/bill/src/main/java/com/remax/bill/ads/RewardedAdController.kt new file mode 100644 index 0000000..461707a --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/RewardedAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceController.kt b/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceController.kt new file mode 100644 index 0000000..6775d16 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceController.kt @@ -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 { + 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() + } + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceSelectionBottomSheet.kt b/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceSelectionBottomSheet.kt new file mode 100644 index 0000000..18728c7 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/AdSourceSelectionBottomSheet.kt @@ -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, + private val currentSource: AdSourceController.AdSource, + private val onItemClick: (AdSourceController.AdSource) -> Unit + ) : RecyclerView.Adapter() { + + 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() + } + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingInitializer.kt b/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingInitializer.kt new file mode 100644 index 0000000..cd10afe --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingInitializer.kt @@ -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 { + 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) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingManager.kt new file mode 100644 index 0000000..ac78634 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/AppOpenBiddingManager.kt @@ -0,0 +1,124 @@ +package com.remax.bill.ads.bidding + +import android.app.Activity +import com.remax.base.report.DataReportManager +import com.remax.bill.BuildConfig +import com.remax.bill.ads.AdResult +import com.remax.bill.ads.AppOpenAdController +import com.remax.bill.ads.log.AdLogger +import com.remax.bill.ads.pangle.PangleAppOpenAdController +import com.remax.bill.ads.topon.TopOnSplashAdController +import com.remax.bill.ads.util.AdmobReflectionUtil +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.util.Locale + +/** + * 开屏广告竞价控制器 + * 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示 + */ +object AppOpenBiddingManager { + + suspend fun bidding( + activity: Activity, + admobAdUnitId: String = BuildConfig.ADMOB_SPLASH_ID, + pangleAdUnitId: String = BuildConfig.PANGLE_SPLASH_ID, + toponPlacementId: String = BuildConfig.TOPON_SPLASH_ID, + ): BiddingWinner { + // 检查是否设置了固定的聚合源 + val source = AdSourceController.getCurrentSource() + if (source != AdSourceController.AdSource.BIDDING) { + // 如果设置了固定源,直接返回对应的 BiddingWinner + return when (source) { + AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB + AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE + AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON + AdSourceController.AdSource.BIDDING -> { + // 不会执行到这里,但为了完整性保留 + performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + } + } + + // 使用竞价逻辑 + return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + + private suspend fun performBidding( + activity: Activity, + admobAdUnitId: String, + pangleAdUnitId: String, + toponPlacementId: String, + ): BiddingWinner { + val context = activity.applicationContext + val admobController = AppOpenAdController.getInstance() + val pangleController = PangleAppOpenAdController.getInstance() + val toponController = TopOnSplashAdController.getInstance() + + // 根据平台配置决定是否参与比价 + val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.APP_OPEN) + val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.APP_OPEN) + val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.APP_OPEN) + + // 异步并行加载启用的广告 + val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope { + val admobDeferred = async { + if (admobEnabled) { + runCatching { admobController.preloadAd(context, admobAdUnitId) }.getOrNull() + } else null + } + val pangleDeferred = async { + if (pangleEnabled) { + runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull() + } else null + } + val toponDeferred = async { + if (toponEnabled) { + runCatching { toponController.preloadAd(context, toponPlacementId) }.getOrNull() + } else null + } + Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await()) + } + + // 获取 AdMob 收益 + val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) { + admobController.getCachedAdPeek(admobAdUnitId)?.ad?.let { ad -> + AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0) + } ?: 0.0 + } else 0.0 + + // 获取 Pangle 收益 + val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) { + pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0 + } else 0.0 + + // 获取 TopOn 收益 + val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) { + toponController.peekCachedAd(toponPlacementId)?.let { splashAd -> + runCatching { splashAd.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0 + } ?: 0.0 + } else 0.0 + + val biddingLog = String.format( + Locale.US, + "开屏竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s", + admobValueUsd, if (admobEnabled) "" else "(禁用)", + pangleValueUsd, if (pangleEnabled) "" else "(禁用)", + toponValueUsd, if (toponEnabled) "" else "(禁用)" + ) + AdLogger.d(biddingLog) + DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog)) + + // 只在启用的平台中选择胜出者 + val winner = when { + admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB + pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE + toponEnabled -> BiddingWinner.TOPON + admobEnabled -> BiddingWinner.ADMOB + pangleEnabled -> BiddingWinner.PANGLE + else -> BiddingWinner.ADMOB // 默认 + } + + return winner + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/BannerBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/BannerBiddingManager.kt new file mode 100644 index 0000000..4337628 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/BannerBiddingManager.kt @@ -0,0 +1,124 @@ +package com.remax.bill.ads.bidding + +import android.app.Activity +import com.remax.base.report.DataReportManager +import com.remax.bill.BuildConfig +import com.remax.bill.ads.AdResult +import com.remax.bill.ads.BannerAdController +import com.remax.bill.ads.log.AdLogger +import com.remax.bill.ads.pangle.PangleBannerAdController +import com.remax.bill.ads.topon.TopOnBannerAdController +import com.remax.bill.ads.util.AdmobReflectionUtil +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.util.Locale + +/** + * Banner广告竞价控制器 + * 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示 + */ +object BannerBiddingManager { + + suspend fun bidding( + activity: Activity, + admobAdUnitId: String = BuildConfig.ADMOB_BANNER_ID, + pangleAdUnitId: String = BuildConfig.PANGLE_BANNER_ID, + toponPlacementId: String = BuildConfig.TOPON_BANNER_ID, + ): BiddingWinner { + // 检查是否设置了固定的聚合源 + val source = AdSourceController.getCurrentSource() + if (source != AdSourceController.AdSource.BIDDING) { + // 如果设置了固定源,直接返回对应的 BiddingWinner + return when (source) { + AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB + AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE + AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON + AdSourceController.AdSource.BIDDING -> { + // 不会执行到这里,但为了完整性保留 + performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + } + } + + // 使用竞价逻辑 + return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + + private suspend fun performBidding( + activity: Activity, + admobAdUnitId: String, + pangleAdUnitId: String, + toponPlacementId: String, + ): BiddingWinner { + val context = activity.applicationContext + val admobController = BannerAdController.getInstance() + val pangleController = PangleBannerAdController.getInstance() + val toponController = TopOnBannerAdController.getInstance() + + // 根据平台配置决定是否参与比价 + val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.BANNER) + val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.BANNER) + val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.BANNER) + + // 异步并行加载启用的广告 + val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope { + val admobDeferred = async { + if (admobEnabled) { + runCatching { admobController.preloadAd(context, admobAdUnitId) }.getOrNull() + } else null + } + val pangleDeferred = async { + if (pangleEnabled) { + runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull() + } else null + } + val toponDeferred = async { + if (toponEnabled) { + runCatching { toponController.preloadAd(activity, toponPlacementId) }.getOrNull() + } else null + } + Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await()) + } + + // 获取 AdMob 收益 + val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) { + admobController.getCurrentAdView()?.let { ad -> + AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0) + } ?: 0.0 + } else 0.0 + + // 获取 Pangle 收益 + val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) { + pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0 + } else 0.0 + + // 获取 TopOn 收益 + val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) { + toponController.peekCachedAd(toponPlacementId)?.let { ad -> + runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0 + } ?: 0.0 + } else 0.0 + + val biddingLog = String.format( + Locale.US, + "Banner竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s", + admobValueUsd, if (admobEnabled) "" else "(禁用)", + pangleValueUsd, if (pangleEnabled) "" else "(禁用)", + toponValueUsd, if (toponEnabled) "" else "(禁用)" + ) + AdLogger.d(biddingLog) + DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog)) + + // 只在启用的平台中选择胜出者 + val winner = when { + admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB + pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE + toponEnabled -> BiddingWinner.TOPON + admobEnabled -> BiddingWinner.ADMOB + pangleEnabled -> BiddingWinner.PANGLE + else -> BiddingWinner.ADMOB // 默认 + } + + return winner + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/BiddingPlatformController.kt b/bill/src/main/java/com/remax/bill/ads/bidding/BiddingPlatformController.kt new file mode 100644 index 0000000..32ce055 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/BiddingPlatformController.kt @@ -0,0 +1,108 @@ +package com.remax.bill.ads.bidding + +/** + * 比价平台控制器 + * 用于控制各广告类型参与比价的平台 + * + * 当前策略: + * - 插页广告:AdMob、Pangle、TopOn 都参与比价 + * - Banner广告:AdMob、TopOn 参与比价 + * - 开屏广告:AdMob、TopOn 参与比价 + * - 原生广告:AdMob、TopOn 参与比价 + * - 全屏原生广告:AdMob、TopOn 参与比价 + */ +object BiddingPlatformController { + + /** + * 广告类型 + */ + enum class AdType { + INTERSTITIAL, // 插页广告 + BANNER, // Banner广告 + APP_OPEN, // 开屏广告 + NATIVE, // 原生广告 + FULL_SCREEN_NATIVE // 全屏原生广告 + } + + /** + * 广告平台 + */ + enum class Platform { + ADMOB, + PANGLE, + TOPON + } + + // ==================== 平台配置(写死) ==================== + + // 插页广告:AdMob + Pangle + TopOn + private const val INTERSTITIAL_ADMOB = true + private const val INTERSTITIAL_PANGLE = true + private const val INTERSTITIAL_TOPON = true + + // Banner广告:AdMob + TopOn + private const val BANNER_ADMOB = true + private const val BANNER_PANGLE = false + private const val BANNER_TOPON = true + + // 开屏广告:AdMob + TopOn + private const val APP_OPEN_ADMOB = true + private const val APP_OPEN_PANGLE = false + private const val APP_OPEN_TOPON = true + + // 原生广告:AdMob + TopOn + private const val NATIVE_ADMOB = true + private const val NATIVE_PANGLE = false + private const val NATIVE_TOPON = true + + // 全屏原生广告:AdMob + TopOn + private const val FULL_SCREEN_NATIVE_ADMOB = true + private const val FULL_SCREEN_NATIVE_PANGLE = false + private const val FULL_SCREEN_NATIVE_TOPON = true + + /** + * 检查指定广告类型的平台是否启用 + */ + fun isPlatformEnabled(adType: AdType, platform: Platform): Boolean { + return when (adType) { + AdType.INTERSTITIAL -> when (platform) { + Platform.ADMOB -> INTERSTITIAL_ADMOB + Platform.PANGLE -> INTERSTITIAL_PANGLE + Platform.TOPON -> INTERSTITIAL_TOPON + } + AdType.BANNER -> when (platform) { + Platform.ADMOB -> BANNER_ADMOB + Platform.PANGLE -> BANNER_PANGLE + Platform.TOPON -> BANNER_TOPON + } + AdType.APP_OPEN -> when (platform) { + Platform.ADMOB -> APP_OPEN_ADMOB + Platform.PANGLE -> APP_OPEN_PANGLE + Platform.TOPON -> APP_OPEN_TOPON + } + AdType.NATIVE -> when (platform) { + Platform.ADMOB -> NATIVE_ADMOB + Platform.PANGLE -> NATIVE_PANGLE + Platform.TOPON -> NATIVE_TOPON + } + AdType.FULL_SCREEN_NATIVE -> when (platform) { + Platform.ADMOB -> FULL_SCREEN_NATIVE_ADMOB + Platform.PANGLE -> FULL_SCREEN_NATIVE_PANGLE + Platform.TOPON -> FULL_SCREEN_NATIVE_TOPON + } + } + } + + /** + * 获取指定广告类型启用的平台列表 + */ + fun getEnabledPlatforms(adType: AdType): List { + 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) +} diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/BiddingWinner.kt b/bill/src/main/java/com/remax/bill/ads/bidding/BiddingWinner.kt new file mode 100644 index 0000000..b55f6ff --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/BiddingWinner.kt @@ -0,0 +1,5 @@ +package com.remax.bill.ads.bidding + +enum class BiddingWinner { + ADMOB, PANGLE, TOPON +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/FullScreenNativeBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/FullScreenNativeBiddingManager.kt new file mode 100644 index 0000000..160d321 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/FullScreenNativeBiddingManager.kt @@ -0,0 +1,124 @@ +package com.remax.bill.ads.bidding + +import android.content.Context +import com.remax.base.report.DataReportManager +import com.remax.bill.BuildConfig +import com.remax.bill.ads.AdResult +import com.remax.bill.ads.FullScreenNativeAdController +import com.remax.bill.ads.log.AdLogger +import com.remax.bill.ads.pangle.PangleFullScreenNativeAdController +import com.remax.bill.ads.topon.TopOnFullScreenNativeAdController +import com.remax.bill.ads.util.AdmobReflectionUtil +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.util.Locale + +/** + * 全屏原生广告竞价控制器 + * 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示 + */ +object FullScreenNativeBiddingManager { + + suspend fun bidding( + context: Context, + admobAdUnitId: String = BuildConfig.ADMOB_FULL_NATIVE_ID, + pangleAdUnitId: String = BuildConfig.PANGLE_FULL_NATIVE_ID, + toponPlacementId: String = BuildConfig.TOPON_FULL_NATIVE_ID, + ): BiddingWinner { + // 检查是否设置了固定的聚合源 + val source = AdSourceController.getCurrentSource() + if (source != AdSourceController.AdSource.BIDDING) { + // 如果设置了固定源,直接返回对应的 BiddingWinner + return when (source) { + AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB + AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE + AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON + AdSourceController.AdSource.BIDDING -> { + // 不会执行到这里,但为了完整性保留 + performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + } + } + + // 使用竞价逻辑 + return performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + + private suspend fun performBidding( + context: Context, + admobAdUnitId: String, + pangleAdUnitId: String, + toponPlacementId: String, + ): BiddingWinner { + val applicationContext = context.applicationContext + val admobController = FullScreenNativeAdController.getInstance() + val pangleController = PangleFullScreenNativeAdController.getInstance() + val toponController = TopOnFullScreenNativeAdController.getInstance() + + // 根据平台配置决定是否参与比价 + val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE) + val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE) + val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.FULL_SCREEN_NATIVE) + + // 异步并行加载启用的广告 + val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope { + val admobDeferred = async { + if (admobEnabled) { + runCatching { admobController.preloadAd(applicationContext, admobAdUnitId) }.getOrNull() + } else null + } + val pangleDeferred = async { + if (pangleEnabled) { + runCatching { pangleController.preloadAd(applicationContext, pangleAdUnitId) }.getOrNull() + } else null + } + val toponDeferred = async { + if (toponEnabled) { + runCatching { toponController.preloadAd(applicationContext, toponPlacementId) }.getOrNull() + } else null + } + Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await()) + } + + // 获取 AdMob 收益 + val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) { + admobController.getCurrentAd()?.let { ad -> + AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0) + } ?: 0.0 + } else 0.0 + + // 获取 Pangle 收益 + val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) { + pangleController.getCurrentAd(pangleAdUnitId)?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0 + } else 0.0 + + // 获取 TopOn 收益 + val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) { + toponController.peekCachedAd(toponPlacementId)?.let { ad -> + runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0 + } ?: 0.0 + } else 0.0 + + val biddingLog = String.format( + Locale.US, + "全屏原生竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s", + admobValueUsd, if (admobEnabled) "" else "(禁用)", + pangleValueUsd, if (pangleEnabled) "" else "(禁用)", + toponValueUsd, if (toponEnabled) "" else "(禁用)" + ) + AdLogger.d(biddingLog) + DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog)) + + // 只在启用的平台中选择胜出者 + val winner = when { + admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB + pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE + toponEnabled -> BiddingWinner.TOPON + admobEnabled -> BiddingWinner.ADMOB + pangleEnabled -> BiddingWinner.PANGLE + else -> BiddingWinner.ADMOB // 默认 + } + + return winner + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/InterstitialBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/InterstitialBiddingManager.kt new file mode 100644 index 0000000..a88fb26 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/InterstitialBiddingManager.kt @@ -0,0 +1,128 @@ +package com.remax.bill.ads.bidding + +import android.app.Activity +import com.blankj.utilcode.util.ToastUtils +import com.remax.base.report.DataReportManager +import com.remax.bill.BuildConfig +import com.remax.bill.ads.AdException +import com.remax.bill.ads.AdResult +import com.remax.bill.ads.InterstitialAdController +import com.remax.bill.ads.log.AdLogger +import com.remax.bill.ads.pangle.PangleInterstitialAdController +import com.remax.bill.ads.topon.TopOnInterstitialAdController +import com.remax.bill.ads.util.AdmobReflectionUtil +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.util.Locale +import kotlin.text.toDouble + +/** + * 插页广告竞价控制器 + * 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示 + */ +object InterstitialBiddingManager { + + suspend fun bidding( + activity: Activity, + admobAdUnitId: String = BuildConfig.ADMOB_INTERSTITIAL_ID, + pangleAdUnitId: String = BuildConfig.PANGLE_INTERSTITIAL_ID, + toponPlacementId: String = BuildConfig.TOPON_INTERSTITIAL_ID, + ): BiddingWinner { + // 检查是否设置了固定的聚合源 + val source = AdSourceController.getCurrentSource() + if (source != AdSourceController.AdSource.BIDDING) { + // 如果设置了固定源,直接返回对应的 BiddingWinner + return when (source) { + AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB + AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE + AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON + AdSourceController.AdSource.BIDDING -> { + // 不会执行到这里,但为了完整性保留 + performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + } + } + + // 使用竞价逻辑 + return performBidding(activity, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + + private suspend fun performBidding( + activity: Activity, + admobAdUnitId: String, + pangleAdUnitId: String, + toponPlacementId: String, + ): BiddingWinner { + val context = activity.applicationContext + val admobController = InterstitialAdController.getInstance() + val pangleController = PangleInterstitialAdController.getInstance() + val toponController = TopOnInterstitialAdController.getInstance() + + // 根据平台配置决定是否参与比价 + val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.INTERSTITIAL) + val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.INTERSTITIAL) + val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.INTERSTITIAL) + + // 异步并行加载启用的广告 + val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope { + val admobDeferred = async { + if (admobEnabled) { + runCatching { admobController.loadAdToCache(context, admobAdUnitId) }.getOrNull() + } else null + } + val pangleDeferred = async { + if (pangleEnabled) { + runCatching { pangleController.preloadAd(context, pangleAdUnitId) }.getOrNull() + } else null + } + val toponDeferred = async { + if (toponEnabled) { + runCatching { toponController.preloadAd(context, toponPlacementId) }.getOrNull() + } else null + } + Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await()) + } + + // 获取 AdMob 收益 + val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) { + admobController.getCachedAdPeek(admobAdUnitId)?.ad?.let { ad -> + AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0) + } ?: 0.0 + } else 0.0 + + // 获取 Pangle 收益 + val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) { + pangleController.getCurrentAd()?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0 + } else 0.0 + + // 获取 TopOn 收益 + val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) { + toponController.getCurrentAd(toponPlacementId)?.let { ad -> + runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0 + } ?: 0.0 + } else 0.0 + + val biddingLog = String.format( + Locale.US, + "插页竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s", + admobValueUsd, if (admobEnabled) "" else "(禁用)", + pangleValueUsd, if (pangleEnabled) "" else "(禁用)", + toponValueUsd, if (toponEnabled) "" else "(禁用)" + ) + AdLogger.d(biddingLog) + DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog)) + + // 只在启用的平台中选择胜出者 + val winner = when { + admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB + pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE + toponEnabled -> BiddingWinner.TOPON + admobEnabled -> BiddingWinner.ADMOB + pangleEnabled -> BiddingWinner.PANGLE + else -> BiddingWinner.ADMOB // 默认 + } + + return winner + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/NativeBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/NativeBiddingManager.kt new file mode 100644 index 0000000..6ae1d69 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/NativeBiddingManager.kt @@ -0,0 +1,124 @@ +package com.remax.bill.ads.bidding + +import android.content.Context +import com.remax.base.report.DataReportManager +import com.remax.bill.BuildConfig +import com.remax.bill.ads.AdResult +import com.remax.bill.ads.NativeAdController +import com.remax.bill.ads.log.AdLogger +import com.remax.bill.ads.pangle.PangleNativeAdController +import com.remax.bill.ads.topon.TopOnNativeAdController +import com.remax.bill.ads.util.AdmobReflectionUtil +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import java.util.Locale + +/** + * 原生广告竞价控制器 + * 同时加载 AdMob、Pangle 和 TopOn,比较收益后选择展示 + */ +object NativeBiddingManager { + + suspend fun bidding( + context: Context, + admobAdUnitId: String = BuildConfig.ADMOB_NATIVE_ID, + pangleAdUnitId: String = BuildConfig.PANGLE_NATIVE_ID, + toponPlacementId: String = BuildConfig.TOPON_NATIVE_ID, + ): BiddingWinner { + // 检查是否设置了固定的聚合源 + val source = AdSourceController.getCurrentSource() + if (source != AdSourceController.AdSource.BIDDING) { + // 如果设置了固定源,直接返回对应的 BiddingWinner + return when (source) { + AdSourceController.AdSource.ADMOB -> BiddingWinner.ADMOB + AdSourceController.AdSource.PANGLE -> BiddingWinner.PANGLE + AdSourceController.AdSource.TOPON -> BiddingWinner.TOPON + AdSourceController.AdSource.BIDDING -> { + // 不会执行到这里,但为了完整性保留 + performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + } + } + + // 使用竞价逻辑 + return performBidding(context, admobAdUnitId, pangleAdUnitId, toponPlacementId) + } + + private suspend fun performBidding( + context: Context, + admobAdUnitId: String, + pangleAdUnitId: String, + toponPlacementId: String, + ): BiddingWinner { + val applicationContext = context.applicationContext + val admobController = NativeAdController.getInstance() + val pangleController = PangleNativeAdController.getInstance() + val toponController = TopOnNativeAdController.getInstance() + + // 根据平台配置决定是否参与比价 + val admobEnabled = BiddingPlatformController.isAdmobEnabled(BiddingPlatformController.AdType.NATIVE) + val pangleEnabled = BiddingPlatformController.isPangleEnabled(BiddingPlatformController.AdType.NATIVE) + val toponEnabled = BiddingPlatformController.isToponEnabled(BiddingPlatformController.AdType.NATIVE) + + // 异步并行加载启用的广告 + val (admobLoadResult, pangleLoadResult, toponLoadResult) = coroutineScope { + val admobDeferred = async { + if (admobEnabled) { + runCatching { admobController.preloadAd(applicationContext, admobAdUnitId) }.getOrNull() + } else null + } + val pangleDeferred = async { + if (pangleEnabled) { + runCatching { pangleController.preloadAd(applicationContext, pangleAdUnitId) }.getOrNull() + } else null + } + val toponDeferred = async { + if (toponEnabled) { + runCatching { toponController.preloadAd(applicationContext, toponPlacementId) }.getOrNull() + } else null + } + Triple(admobDeferred.await(), pangleDeferred.await(), toponDeferred.await()) + } + + // 获取 AdMob 收益 + val admobValueUsd = if (admobEnabled && admobLoadResult is AdResult.Success<*>) { + admobController.getCurrentAd()?.let { ad -> + AdmobReflectionUtil.getRevenue(ad)?.valueMicros?.toDouble()?.div(1_000_000.0) + } ?: 0.0 + } else 0.0 + + // 获取 Pangle 收益 + val pangleValueUsd = if (pangleEnabled && pangleLoadResult is AdResult.Success<*>) { + pangleController.getCurrentAd(pangleAdUnitId)?.pagRevenueInfo?.winEcpm?.revenue?.toDoubleOrNull() ?: 0.0 + } else 0.0 + + // 获取 TopOn 收益 + val toponValueUsd = if (toponEnabled && toponLoadResult is AdResult.Success<*>) { + toponController.peekCachedAd(toponPlacementId)?.let { ad -> + runCatching { ad.checkValidAdCaches().firstOrNull()?.publisherRevenue }.getOrNull() ?: 0.0 + } ?: 0.0 + } else 0.0 + + val biddingLog = String.format( + Locale.US, + "原生竞价结果 -> AdMob: %.8f 美元%s, Pangle: %.8f 美元%s, TopOn: %.8f 美元%s", + admobValueUsd, if (admobEnabled) "" else "(禁用)", + pangleValueUsd, if (pangleEnabled) "" else "(禁用)", + toponValueUsd, if (toponEnabled) "" else "(禁用)" + ) + AdLogger.d(biddingLog) + DataReportManager.reportDataByName("ThinkingData", "bidding", mapOf("log" to biddingLog)) + + // 只在启用的平台中选择胜出者 + val winner = when { + admobEnabled && admobValueUsd >= pangleValueUsd && admobValueUsd >= toponValueUsd -> BiddingWinner.ADMOB + pangleEnabled && pangleValueUsd >= toponValueUsd && pangleValueUsd >= admobValueUsd -> BiddingWinner.PANGLE + toponEnabled -> BiddingWinner.TOPON + admobEnabled -> BiddingWinner.ADMOB + pangleEnabled -> BiddingWinner.PANGLE + else -> BiddingWinner.ADMOB // 默认 + } + + return winner + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/bidding/RewardedBiddingManager.kt b/bill/src/main/java/com/remax/bill/ads/bidding/RewardedBiddingManager.kt new file mode 100644 index 0000000..f6a2470 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/bidding/RewardedBiddingManager.kt @@ -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 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/config/AdConfig.kt b/bill/src/main/java/com/remax/bill/ads/config/AdConfig.kt new file mode 100644 index 0000000..a591ecf --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/config/AdConfig.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/config/AdConfigData.kt b/bill/src/main/java/com/remax/bill/ads/config/AdConfigData.kt new file mode 100644 index 0000000..7aaa541 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/config/AdConfigData.kt @@ -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 + ) +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/config/AdConfigManager.kt b/bill/src/main/java/com/remax/bill/ads/config/AdConfigManager.kt new file mode 100644 index 0000000..6c8b9e1 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/config/AdConfigManager.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/ext/AdShowExt.kt b/bill/src/main/java/com/remax/bill/ads/ext/AdShowExt.kt new file mode 100644 index 0000000..f85d3cd --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/ext/AdShowExt.kt @@ -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 { + 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 { + 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 { + 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 { + 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() + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/interceptor/AdInterceptor.kt b/bill/src/main/java/com/remax/bill/ads/interceptor/AdInterceptor.kt new file mode 100644 index 0000000..f82744a --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/interceptor/AdInterceptor.kt @@ -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 +} + +/** + * 展示次数限制拦截器 + */ +class ShowCountLimitInterceptor : AdInterceptor { + companion object { + private const val TAG = "AdModule" + } + + override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult { + // 检查日展示次数 + 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 { + // 检查展示间隔 + 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 { + // 检查日点击次数 + 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 { + if (!_isGlobalAdEnabled) { + AdLogger.w("[${adConfig.getConfigKey()}] 全局广告已关闭,跳过广告展示") + return AdResult.Failure( + AdException( + code = -100, + message = "全局广告已关闭" + ) + ) + } + + return AdResult.Success(Unit) + } +} + +/** + * 拦截器链 + */ +class InterceptorChain( + private val interceptors: List +) : AdInterceptor { + override suspend fun intercept(context: Context, adConfig: AdConfig): AdResult { + 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" + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/log/AdLogger.kt b/bill/src/main/java/com/remax/bill/ads/log/AdLogger.kt new file mode 100644 index 0000000..50f323f --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/log/AdLogger.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleAppOpenAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleAppOpenAdController.kt new file mode 100644 index 0000000..3a9e57c --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleAppOpenAdController.kt @@ -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 { + 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 { + // 累积加载次数统计 + 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 { + // 累积触发广告展示次数统计 + 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 { + 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) { + val data = mutableMapOf( + "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) + } + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleBannerAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleBannerAdController.kt new file mode 100644 index 0000000..8b5f992 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleBannerAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 ?: "" + ) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleFullScreenNativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleFullScreenNativeAdController.kt new file mode 100644 index 0000000..cb7c96c --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleFullScreenNativeAdController.kt @@ -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() + private val interceptorChain = InterceptorChain( + interceptors = listOf( + GlobalAdSwitchInterceptor(), + ShowCountLimitInterceptor(), + ShowIntervalLimitInterceptor(), + ClickLimitInterceptor() + ) + ) + + private fun reportAdData(eventName: String, params: Map) { + val data = mutableMapOf( + "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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleInterstitialAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleInterstitialAdController.kt new file mode 100644 index 0000000..af2b2dc --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleInterstitialAdController.kt @@ -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 { + 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 { + // 累积加载次数统计 + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 ?: "" + ) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleManager.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleManager.kt new file mode 100644 index 0000000..a1ce2b3 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleManager.kt @@ -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.Loading) + val initializationState: StateFlow> = _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 { + 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 { + 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广告控制器已清理") + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleNativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleNativeAdController.kt new file mode 100644 index 0000000..0923408 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleNativeAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 ?: "" + ) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/pangle/PangleRewardedAdController.kt b/bill/src/main/java/com/remax/bill/ads/pangle/PangleRewardedAdController.kt new file mode 100644 index 0000000..12ef463 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/pangle/PangleRewardedAdController.kt @@ -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 { + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 ?: "" + ) + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/provider/AdModuleProvider.kt b/bill/src/main/java/com/remax/bill/ads/provider/AdModuleProvider.kt new file mode 100644 index 0000000..d838ae8 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/provider/AdModuleProvider.kt @@ -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?, + selection: String?, + selectionArgs: Array?, + 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?): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnBannerAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnBannerAdController.kt new file mode 100644 index 0000000..b01d825 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnBannerAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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() + 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) { + val data = mutableMapOf( + "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) + } + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnFullScreenNativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnFullScreenNativeAdController.kt new file mode 100644 index 0000000..4064f93 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnFullScreenNativeAdController.kt @@ -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() + 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 { + 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 广告显示结果 + */ + suspend fun showAdInContainer( + context: Context, + container: ViewGroup, + lifecycleOwner: LifecycleOwner, + placementId: String? = null + ): AdResult { + 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 { + 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 { + 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() + 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) { + val data = mutableMapOf( + "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 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnInterstitialAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnInterstitialAdController.kt new file mode 100644 index 0000000..1fd84f4 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnInterstitialAdController.kt @@ -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() + + /** + * 缓存的广告实体 + */ + 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 { + 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 { + 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? = null + private var showContinuation: CancellableContinuation>? = null + private var lastAdInfo: TUAdInfo? = null + private var cacheTime: Long = System.currentTimeMillis() + + fun attachLoadContinuation(continuation: CancellableContinuation) { + 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) { + showContinuation?.let { continuation -> + if (continuation.isActive) { + continuation.resume(result) + } + } + showContinuation = null + } + + suspend fun awaitShow(activity: Activity): AdResult { + 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) { + val data = mutableMapOf( + "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 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnManager.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnManager.kt new file mode 100644 index 0000000..6fe54f0 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnManager.kt @@ -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.Loading) + val initializationState: StateFlow> = _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 { + 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 { + 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广告控制器已清理") + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnNativeAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnNativeAdController.kt new file mode 100644 index 0000000..cde9f57 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnNativeAdController.kt @@ -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() + 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 { + 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 { + 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 { + 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) { + val data = mutableMapOf( + "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 + ) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnRewardedAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnRewardedAdController.kt new file mode 100644 index 0000000..bc951dd --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnRewardedAdController.kt @@ -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() + + /** + * 缓存的激励广告实体 + */ + 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 { + 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 { + 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? = null + private var showContinuation: kotlinx.coroutines.CancellableContinuation>? = 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) { + 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) { + showContinuation?.let { continuation -> + if (continuation.isActive) { + continuation.resume(result) + } + } + showContinuation = null + } + + suspend fun awaitShow(activity: Activity, onRewardEarned: ((String, Int) -> Unit)?): AdResult { + 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) { + val data = mutableMapOf( + "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 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/topon/TopOnSplashAdController.kt b/bill/src/main/java/com/remax/bill/ads/topon/TopOnSplashAdController.kt new file mode 100644 index 0000000..ea55468 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/topon/TopOnSplashAdController.kt @@ -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() + private val maxCacheSizePerAdUnit = DEFAULT_CACHE_SIZE_PER_AD_UNIT + + // 存储每个 placementId 对应的 continuation,用于在 onAdDismiss 回调中恢复 + private val continuationMap = mutableMapOf>>() + + // 拦截器链 + 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 { + 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 { + 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 { + // 累积触发广告展示次数统计 + 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(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 { + 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) { + val data = mutableMapOf( + "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) + } + } +} + diff --git a/bill/src/main/java/com/remax/bill/ads/util/AdmobReflectionUtil.kt b/bill/src/main/java/com/remax/bill/ads/util/AdmobReflectionUtil.kt new file mode 100644 index 0000000..8b90e34 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/util/AdmobReflectionUtil.kt @@ -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): 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 + } + } +} diff --git a/bill/src/main/java/com/remax/bill/ads/util/PositionGet.kt b/bill/src/main/java/com/remax/bill/ads/util/PositionGet.kt new file mode 100644 index 0000000..38377b6 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ads/util/PositionGet.kt @@ -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 = 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 "" + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/BannerAdView.kt b/bill/src/main/java/com/remax/bill/ui/BannerAdView.kt new file mode 100644 index 0000000..5114ee5 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/BannerAdView.kt @@ -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(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广告重置,目前无需特殊处理 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdActivity.kt b/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdActivity.kt new file mode 100644 index 0000000..f09f59a --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdActivity.kt @@ -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 广告显示结果 + */ + suspend fun start(activity: Activity, showInterstitial: Boolean = true): AdResult { + 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>? = null + + /** + * 设置结果并恢复continuation + */ + fun setResult(result: AdResult) { + 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(R.id.adContainer), + lifecycleOwner = this@FullScreenNativeAdActivity, + adUnitId = BuildConfig.ADMOB_FULL_NATIVE_ID + )) { + is AdResult.Success -> { + findViewById(R.id.rl_top_buttons).apply { + isVisible = true + findViewById(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() { + // 禁用返回键,只能通过广告关闭按钮关闭 + } +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdView.kt new file mode 100644 index 0000000..f39fdea --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/FullScreenNativeAdView.kt @@ -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(R.id.tv_ad_title) + val descView = adView.findViewById(R.id.tv_ad_description) + val ctaButton = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val mediaView = adView.findViewById(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) + } + } + +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/NativeAdStyle.kt b/bill/src/main/java/com/remax/bill/ui/NativeAdStyle.kt new file mode 100644 index 0000000..6940ecd --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/NativeAdStyle.kt @@ -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" + ) +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/NativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/NativeAdView.kt new file mode 100644 index 0000000..b023756 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/NativeAdView.kt @@ -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(R.id.tv_ad_title) + val ctaButton = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val ratingLayout = adView.findViewById(R.id.startLL) + val descView = adView.findViewById(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() + 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, 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 + ) + } + } + +} \ No newline at end of file diff --git a/bill/src/main/java/com/remax/bill/ui/dialog/ADLoadingDialog.kt b/bill/src/main/java/com/remax/bill/ui/dialog/ADLoadingDialog.kt new file mode 100644 index 0000000..4fe742e --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/dialog/ADLoadingDialog.kt @@ -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 + } + } +} diff --git a/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdActivity.kt b/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdActivity.kt new file mode 100644 index 0000000..4528ca7 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdActivity.kt @@ -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 { + 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>? = null + + fun setResult(result: AdResult) { + 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(R.id.rl_top_buttons)?.apply { + isVisible = true + findViewById(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() { + // 禁用返回键 + } +} diff --git a/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdView.kt new file mode 100644 index 0000000..614ebd8 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/pangle/PangleFullScreenNativeAdView.kt @@ -0,0 +1,153 @@ +package com.remax.bill.ui.pangle + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.lifecycle.LifecycleOwner +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAd +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdData +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionListener +import com.bumptech.glide.Glide +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionCallback +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGViewBinder +import com.remax.bill.R +import com.remax.bill.ads.log.AdLogger + +/** + * Pangle全屏原生广告视图组件 + * 提供全屏原生广告的布局创建、数据绑定和交互注册 + */ +class PangleFullScreenNativeAdView { + + companion object { + private const val TAG = "PangleFullScreenNativeView" + } + + /** + * 创建并绑定全屏原生广告视图到容器中 + */ + fun bindFullScreenNativeAdToContainer( + context: Context, + container: ViewGroup, + nativeAd: PAGNativeAd, + @Suppress("UNUSED_PARAMETER") lifecycleOwner: LifecycleOwner? = null, + interactionListener: PAGNativeAdInteractionCallback? = null + ): Boolean { + return try { + container.removeAllViews() + + val adView = LayoutInflater.from(context) + .inflate(R.layout.layout_pangle_fullscreen_native_ad, container, false) + + val nativeAdData = nativeAd.nativeAdData + val creativeViews = bindNativeAdData(context, adView, nativeAdData) + + container.addView(adView) + + val clickViews = arrayListOf().apply { + adView.findViewById(R.id.tv_ad_title)?.let { add(it) } + adView.findViewById(R.id.tv_ad_description)?.let { add(it) } + adView.findViewById(R.id.btn_ad_cta)?.let { add(it) } + adView.findViewById(R.id.iv_ad_icon)?.let { add(it) } + } + val binder = PAGViewBinder.Builder(container) + .titleTextView(adView.findViewById(R.id.tv_ad_title)) + .descriptionTextView( adView.findViewById(R.id.tv_ad_description)) + .logoViewGroup(adView.findViewById(R.id.fl_ad_logo)) + .iconImageView(adView.findViewById(R.id.iv_ad_icon)) + .mediaContentViewGroup(adView.findViewById(R.id.fl_ad_media)) + .build() + + @Suppress("UNCHECKED_CAST") + nativeAd.registerViewForInteraction( + binder, + clickViews as MutableList, + interactionListener + ) + + true + } catch (e: Exception) { + AdLogger.e("Pangle全屏原生广告视图绑定失败", 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("Pangle全屏原生加载视图创建失败", e) + } + } + + private fun bindNativeAdData( + context: Context, + adView: View, + nativeAdData: PAGNativeAdData + ): MutableList? { + try { + val titleView = adView.findViewById(R.id.tv_ad_title) + val descView = adView.findViewById(R.id.tv_ad_description) + val ctaView = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val mediaContainer = adView.findViewById(R.id.fl_ad_media) + val logoContainer = adView.findViewById(R.id.fl_ad_logo) + + val creativeViews = mutableListOf() + + titleView?.text = nativeAdData.title ?: "" + descView?.text = nativeAdData.description ?: "" + ctaView?.text = nativeAdData.buttonText ?: "INSTALL" + + nativeAdData.icon?.let { icon -> + try { + Glide.with(context) + .load(icon.imageUrl) + .into(iconView ?: return@let) + iconView?.visibility = View.VISIBLE + } catch (e: Exception) { + iconView?.visibility = View.GONE + } + } ?: run { + iconView?.visibility = View.GONE + } + + mediaContainer?.let { container -> + container.removeAllViews() + nativeAdData.mediaView?.let { mediaView -> + container.addView(mediaView) + container.visibility = View.VISIBLE + creativeViews.add(mediaView) + } ?: run { + container.visibility = View.GONE + } + } + + logoContainer?.let { container -> + container.removeAllViews() + nativeAdData.adLogoView?.let { logoView -> + container.addView(logoView) + container.visibility = View.VISIBLE + } ?: run { + container.visibility = View.GONE + } + } + + return creativeViews + + } catch (e: Exception) { + AdLogger.e("Pangle全屏原生广告数据绑定失败", e) + } + return null + } +} diff --git a/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdStyle.kt b/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdStyle.kt new file mode 100644 index 0000000..a23e145 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdStyle.kt @@ -0,0 +1,26 @@ +package com.remax.bill.ui.pangle + +/** + * Pangle原生广告样式模型类 + * 定义Pangle原生广告的布局样式 + */ +data class PangleNativeAdStyle( + val layoutResId: Int +) { + + companion object { + /** + * 标准样式:使用layout_pangle_native_ads布局 + */ + val STANDARD = PangleNativeAdStyle( + layoutResId = com.remax.bill.R.layout.layout_pangle_native_ads, + ) + + /** + * 大原生样式:垂直布局,图标+标题+描述+CTA按钮 + */ + val LARGE = PangleNativeAdStyle( + layoutResId = com.remax.bill.R.layout.layout_pangle_native_ads_large, + ) + } +} diff --git a/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdView.kt new file mode 100644 index 0000000..3882328 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/pangle/PangleNativeAdView.kt @@ -0,0 +1,194 @@ +package com.remax.bill.ui.pangle + +import android.content.Context +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAd +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdData +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionListener +import com.bumptech.glide.Glide +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGNativeAdInteractionCallback +import com.bytedance.sdk.openadsdk.api.nativeAd.PAGViewBinder +import com.remax.bill.R +import com.remax.bill.ads.log.AdLogger +import java.util.ArrayList + +/** + * Pangle原生广告UI视图组件 + * 封装Pangle原生广告的布局创建和数据绑定逻辑 + */ +class PangleNativeAdView { + + companion object { + private const val TAG = "PangleNativeAdView" + } + + /** + * 创建并绑定Pangle原生广告视图到容器中 + * @param context 上下文 + * @param container 目标容器(根视图) + * @param nativeAd Pangle原生广告对象 + * @param style 广告样式,默认为标准样式 + * @param clickViews 需要注册普通点击事件的View集合(可选) + * @param creativeViews 需要注册创意点击事件的View集合(可选) + * @param dislikeView 自定义关闭View(可选) + * @return 是否绑定成功 + */ + fun bindNativeAdToContainer( + context: Context, + container: ViewGroup, + nativeAd: PAGNativeAd, + @Suppress("UNUSED_PARAMETER") style: PangleNativeAdStyle = PangleNativeAdStyle.STANDARD, + clickViews: List? = null, + dislikeView: View? = null, + interactionListener: PAGNativeAdInteractionCallback? = null + ): Boolean { + return try { + // 清空容器 + container.removeAllViews() + + // 创建原生广告布局 + val adView = createNativeAdLayout(context, style) + + // 获取广告数据 + val nativeAdData = nativeAd.getNativeAdData() + + // 绑定广告数据 + bindNativeAdData(context, adView, nativeAdData) + + // 添加到容器 + container.addView(adView) + + // 注册交互视图和回调(必须在显示前调用) + val clickViewsList = ArrayList().apply { + clickViews?.forEach { add(it) } + // 如果没有指定,默认将标题、描述、按钮、图标添加到点击视图 + if (clickViews.isNullOrEmpty()) { + adView.findViewById(R.id.tv_ad_title)?.let { add(it) } + adView.findViewById(R.id.tv_ad_description)?.let { add(it) } + adView.findViewById(R.id.btn_ad_cta)?.let { add(it) } + adView.findViewById(R.id.iv_ad_icon)?.let { add(it) } + } + } + + val binder = PAGViewBinder.Builder(container) + .titleTextView(adView.findViewById(R.id.tv_ad_title)) + .descriptionTextView( adView.findViewById(R.id.tv_ad_description)) + .logoViewGroup(adView.findViewById(R.id.fl_ad_logo)) + .iconImageView(adView.findViewById(R.id.iv_ad_icon)) + .dislikeView(dislikeView) + .build() + @Suppress("UNCHECKED_CAST") + nativeAd.registerViewForInteraction( + binder, + clickViewsList as MutableList, + interactionListener + ) + + AdLogger.d("Pangle原生广告视图绑定成功") + true + } catch (e: Exception) { + AdLogger.e("Pangle原生广告视图绑定失败", e) + false + } + } + + /** + * 创建原生广告布局 + */ + private fun createNativeAdLayout( + context: Context, + style: PangleNativeAdStyle + ): ViewGroup { + return LayoutInflater.from(context) + .inflate(style.layoutResId, null) as ViewGroup + } + + /** + * 绑定Pangle原生广告数据到视图 + */ + private fun bindNativeAdData( + context: Context, + adView: ViewGroup, + nativeAdData: PAGNativeAdData + ) { + try { + val titleView = adView.findViewById(R.id.tv_ad_title) + val ctaButton = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val descView = adView.findViewById(R.id.tv_ad_description) + val logoContainer = adView.findViewById(R.id.fl_ad_logo) + + // 设置广告标题 + titleView?.text = nativeAdData.title ?: "" + + // 设置CTA按钮 + ctaButton?.text = nativeAdData.buttonText ?: "INSTALL" + + // 设置广告描述 + descView?.text = nativeAdData.description ?: "" + + // 设置图标 + nativeAdData.icon?.let { icon -> + loadImage(context, icon.imageUrl, iconView) + iconView?.visibility = View.VISIBLE + } ?: run { + iconView?.visibility = View.GONE + } + + // ad logo + logoContainer?.let { container -> + container.removeAllViews() + nativeAdData.adLogoView?.let { logoView -> + container.addView(logoView) + container.visibility = View.VISIBLE + } ?: run { + container.visibility = View.GONE + } + } + + AdLogger.d("Pangle原生广告数据绑定完成") + + } catch (e: Exception) { + AdLogger.e("绑定Pangle原生广告数据失败", e) + } + } + + /** + * 加载图片(使用Glide) + */ + private fun loadImage(context: Context, imageUrl: String?, imageView: ImageView?) { + if (imageUrl.isNullOrEmpty() || imageView == null) { + return + } + try { + Glide.with(context) + .load(imageUrl) + .into(imageView) + } catch (e: Exception) { + AdLogger.e("加载Pangle原生广告图片失败: $imageUrl", e) + } + } + + /** + * 创建加载失败的占位视图 + */ + fun createErrorView(context: Context, errorMessage: String? = null): View { + return TextView(context).apply { + text = errorMessage ?: "广告加载失败" + textSize = 12f + setTextColor(0xFF999999.toInt()) + gravity = Gravity.CENTER + setPadding(16, 16, 16, 16) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + } +} diff --git a/bill/src/main/java/com/remax/bill/ui/topon/ToponBannerAdView.kt b/bill/src/main/java/com/remax/bill/ui/topon/ToponBannerAdView.kt new file mode 100644 index 0000000..e6ee789 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/topon/ToponBannerAdView.kt @@ -0,0 +1,78 @@ +package com.remax.bill.ui.topon + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.remax.bill.R +import com.remax.bill.ads.log.AdLogger +import com.thinkup.banner.api.TUBannerView + +/** + * TopOn Banner广告UI视图组件 + * 封装TopOn Banner广告的布局创建和显示逻辑 + */ +class ToponBannerAdView { + + companion object { + private const val TAG = "ToponBannerAdView" + } + + /** + * 创建并绑定TopOn Banner广告视图到容器中 + * @param context 上下文 + * @param container 目标容器 + * @param bannerView TopOn的TUBannerView + * @param onExpandCallback 展开状态变化回调(已弃用,传null即可) + * @return 是否绑定成功 + */ + fun bindBannerAdToContainer( + context: Context, + container: ViewGroup, + bannerView: TUBannerView, + onExpandCallback: ((Boolean) -> Unit)? = null + ): Boolean { + return try { + // 清空容器 + container.removeAllViews() + + // 创建Banner广告容器布局 + val bannerContainer = createBannerContainerLayout(context) + + // 将TUBannerView添加到容器中 + val adContainer = bannerContainer.findViewById(R.id.fl_ad_container) + adContainer.removeAllViews() + adContainer.addView(bannerView) + + // 添加到目标容器 + container.addView(bannerContainer) + + AdLogger.d("TopOn Banner广告视图绑定成功") + true + } catch (e: Exception) { + AdLogger.e("TopOn Banner广告视图绑定失败", e) + false + } + } + + /** + * 创建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() { + // TopOn Banner广告重置,目前无需特殊处理 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdActivity.kt b/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdActivity.kt new file mode 100644 index 0000000..8275ef4 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdActivity.kt @@ -0,0 +1,164 @@ +package com.remax.bill.ui.topon + +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.topon.TopOnFullScreenNativeAdController +import com.remax.bill.ads.topon.TopOnManager +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * TopOn全屏原生广告展示页 + */ +class ToponFullScreenNativeAdActivity : AppCompatActivity() { + + companion object { + private const val EXTRA_SHOW_INTERSTITIAL = "showInterstitial" + + suspend fun start(activity: Activity, showInterstitial: Boolean = true): AdResult { + return suspendCancellableCoroutine { continuation -> + val intent = Intent(activity, ToponFullScreenNativeAdActivity::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>? = null + + fun setResult(result: AdResult) { + continuation?.let { cont -> + if (cont.isActive) { + cont.resume(result) + } + } + continuation = null + } + } + + private val fullScreenNativeController = TopOnManager.Controllers.fullScreenNative + private val interstitialController = TopOnManager.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_topon_full_screen_native_ad) + loadAndShowFullScreenNativeAd() + if (shouldShowInterstitial) { + showInterstitialAd {} + } + } + + private fun loadAndShowFullScreenNativeAd() { + lifecycleScope.launch { + try { + when (val result = fullScreenNativeController.showAdInContainer( + context = this@ToponFullScreenNativeAdActivity, + container = findViewById(R.id.adContainer), + lifecycleOwner = this@ToponFullScreenNativeAdActivity, + placementId = BuildConfig.TOPON_FULL_NATIVE_ID + )) { + is AdResult.Success -> { + findViewById(R.id.rl_top_buttons)?.apply { + isVisible = true + findViewById(R.id.btn_close)?.setOnClickListener { + TopOnFullScreenNativeAdController.getInstance().closeEvent(placementId = BuildConfig.TOPON_FULL_NATIVE_ID) + closeAdAndFinish() + } + } + AdLogger.d("TopOn全屏原生广告展示成功") + } + + is AdResult.Failure -> { + setResult(result) + closeAdAndFinish() + } + + AdResult.Loading -> { + AdLogger.d("TopOn全屏原生广告加载中") + } + } + } catch (e: Exception) { + AdLogger.e("TopOn全屏原生广告展示异常:${e.message}") + setResult( + AdResult.Failure( + AdException( + code = -2, + message = "TopOn全屏原生广告加载异常: ${e.message}", + cause = e + ) + ) + ) + closeAdAndFinish() + } + } + } + + private fun showInterstitialAd(onFinished: () -> Unit) { + lifecycleScope.launch { + try { + when (val result = AdShowExt.showInterstitialAd( + activity = this@ToponFullScreenNativeAdActivity, + 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() { + // 禁用返回键 + } +} + diff --git a/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdView.kt new file mode 100644 index 0000000..1caab57 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/topon/ToponFullScreenNativeAdView.kt @@ -0,0 +1,225 @@ +package com.remax.bill.ui.topon + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.lifecycle.LifecycleOwner +import com.bumptech.glide.Glide +import com.remax.bill.R +import com.remax.bill.ads.log.AdLogger +import com.thinkup.nativead.api.NativeAd +import com.thinkup.nativead.api.TUNativeAdView +import com.thinkup.nativead.api.TUNativeMaterial +import com.thinkup.nativead.api.TUNativePrepareInfo + +/** + * TopOn全屏原生广告视图组件 + * 提供全屏原生广告的布局创建、数据绑定和交互注册 + */ +class ToponFullScreenNativeAdView { + + companion object { + private const val TAG = "ToponFullScreenNativeAdView" + } + + /** + * 创建并绑定全屏原生广告视图到容器中 + */ + fun bindFullScreenNativeAdToContainer( + context: Context, + container: ViewGroup, + nativeAd: NativeAd, + lifecycleOwner: LifecycleOwner + ): Boolean { + return try { + container.removeAllViews() + + val isNativeExpress = nativeAd.isNativeExpress() + if (isNativeExpress) { + bindTemplateAd(context, container, nativeAd) + } else { + bindSelfRenderAd(context, container, nativeAd) + } + + AdLogger.d("TopOn全屏原生广告视图绑定成功") + true + } catch (e: Exception) { + AdLogger.e("TopOn全屏原生广告视图绑定失败", 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("TopOn全屏原生加载视图创建失败", e) + } + } + + /** + * 绑定模板渲染广告 + */ + private fun bindTemplateAd( + context: Context, + container: ViewGroup, + nativeAd: NativeAd + ) { + try { + val nativeAdView = TUNativeAdView(context) + nativeAd.renderAdContainer(nativeAdView, null) + nativeAd.prepare(nativeAdView, null) + container.addView(nativeAdView) + AdLogger.d("TopOn全屏模板渲染广告绑定完成") + } catch (e: Exception) { + AdLogger.e("TopOn全屏模板渲染广告绑定失败", e) + throw e + } + } + + /** + * 绑定自渲染广告 + */ + private fun bindSelfRenderAd( + context: Context, + container: ViewGroup, + nativeAd: NativeAd + ) { + try { + val adView = createFullScreenNativeAdLayout(context) + val nativeAdView = TUNativeAdView(context) + val material = nativeAd.getAdMaterial() + bindNativeAdData(adView, material) + val prepareInfo = createPrepareInfo(adView) + nativeAd.renderAdContainer(nativeAdView, adView) + nativeAd.prepare(nativeAdView, prepareInfo) + container.addView(nativeAdView) + AdLogger.d("TopOn全屏自渲染广告绑定完成") + } catch (e: Exception) { + AdLogger.e("TopOn全屏自渲染广告绑定失败", e) + throw e + } + } + + /** + * 创建全屏原生广告布局 + */ + private fun createFullScreenNativeAdLayout(context: Context): ViewGroup { + return LayoutInflater.from(context) + .inflate(R.layout.layout_topon_fullscreen_native_ad, null) as ViewGroup + } + + /** + * 创建 PrepareInfo + */ + private fun createPrepareInfo(adView: ViewGroup): TUNativePrepareInfo { + val prepareInfo = TUNativePrepareInfo() + adView.findViewById(R.id.tv_ad_title)?.let { + prepareInfo.setTitleView(it) + } + adView.findViewById(R.id.tv_ad_description)?.let { + prepareInfo.descView = it + } + adView.findViewById(R.id.btn_ad_cta)?.let { + prepareInfo.ctaView = it + } + adView.findViewById(R.id.iv_ad_icon)?.let { + prepareInfo.setIconView(it) + } + adView.findViewById(R.id.fl_ad_media)?.let { + prepareInfo.setMainImageView(it) + } + return prepareInfo + } + + /** + * 绑定原生广告数据 + */ + private fun bindNativeAdData( + adView: ViewGroup, + material: TUNativeMaterial + ) { + try { + val titleView = adView.findViewById(R.id.tv_ad_title) + val ctaButton = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val descView = adView.findViewById(R.id.tv_ad_description) + + titleView?.text = material.title ?: "Test TopOn Ads" + ctaButton?.text = material.callToActionText ?: "INSTALL" + descView?.text = material.descriptionText ?: "" + + material.iconImageUrl?.let { iconUrl -> + iconView?.let { view -> + loadImage(view.context, iconUrl, view) + view.visibility = View.VISIBLE + } + } ?: run { + iconView?.setImageResource(android.R.drawable.ic_menu_info_details) + iconView?.visibility = View.VISIBLE + } + + // 处理主图 + material.mainImageUrl?.let { mainImageUrl -> + val mediaContainer = adView.findViewById(R.id.fl_ad_media) + mediaContainer?.let { container -> + container.removeAllViews() + val imageView = ImageView(container.context) + imageView.scaleType = ImageView.ScaleType.CENTER_CROP + loadImage(container.context, mainImageUrl, imageView) + container.addView(imageView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + container.visibility = View.VISIBLE + } + } ?: run { + adView.findViewById(R.id.fl_ad_media)?.visibility = View.GONE + } + + AdLogger.d("TopOn全屏原生广告数据绑定完成") + } catch (e: Exception) { + AdLogger.e("绑定TopOn全屏原生广告数据失败", e) + } + } + + /** + * 加载图片 + */ + private fun loadImage(context: Context, imageUrl: String?, imageView: ImageView?) { + if (imageUrl.isNullOrEmpty() || imageView == null) { + return + } + try { + Glide.with(context) + .load(imageUrl) + .into(imageView) + } catch (e: Exception) { + AdLogger.e("加载TopOn全屏原生广告图片失败: $imageUrl", e) + } + } + + /** + * 创建错误视图 + */ + 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 + ) + } + } +} + diff --git a/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdStyle.kt b/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdStyle.kt new file mode 100644 index 0000000..f04d9ee --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdStyle.kt @@ -0,0 +1,33 @@ +package com.remax.bill.ui.topon + +/** + * TopOn原生广告样式模型类 + * 定义TopOn原生广告的布局样式 + */ +data class ToponNativeAdStyle( + val layoutResId: Int, + val description: String, + val heightDp: Int = 0 // 高度dp,0表示自适应 +) { + + companion object { + /** + * 标准样式:水平布局,图标+标题+描述+按钮 + */ + val STANDARD = ToponNativeAdStyle( + layoutResId = com.remax.bill.R.layout.layout_topon_native_ads, + description = "normal", + heightDp = 72 + ) + + /** + * 大原生样式:垂直布局,图标+标题+描述+CTA按钮 + */ + val LARGE = ToponNativeAdStyle( + layoutResId = com.remax.bill.R.layout.layout_topon_native_ads_large, + description = "large", + heightDp = 146 + ) + } +} + diff --git a/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdView.kt b/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdView.kt new file mode 100644 index 0000000..15e79e6 --- /dev/null +++ b/bill/src/main/java/com/remax/bill/ui/topon/ToponNativeAdView.kt @@ -0,0 +1,259 @@ +package com.remax.bill.ui.topon + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.bumptech.glide.Glide +import com.remax.bill.R +import com.remax.bill.ads.log.AdLogger +import com.thinkup.nativead.api.NativeAd +import com.thinkup.nativead.api.TUNativeAdView +import com.thinkup.nativead.api.TUNativeMaterial + +/** + * TopOn原生广告UI视图组件 + * 封装TopOn原生广告的布局创建和数据绑定逻辑 + */ +class ToponNativeAdView { + + companion object { + private const val TAG = "ToponNativeAdView" + } + + /** + * 创建并绑定TopOn原生广告视图到容器中 + * @param context 上下文 + * @param container 目标容器 + * @param nativeAd TopOn原生广告对象 + * @param style 广告样式,默认为标准样式 + * @return 是否绑定成功 + */ + fun bindNativeAdToContainer( + context: Context, + container: ViewGroup, + nativeAd: NativeAd, + style: ToponNativeAdStyle = ToponNativeAdStyle.STANDARD + ): Boolean { + return try { + // 清空容器 + container.removeAllViews() + + // 判断是自渲染还是模板渲染 + val isNativeExpress = nativeAd.isNativeExpress() + + if (isNativeExpress) { + // 模板渲染:直接使用广告平台返回的渲染好的view + bindTemplateAd(context, container, nativeAd) + } else { + // 自渲染:通过素材拼接 + bindSelfRenderAd(context, container, nativeAd, style) + } + + AdLogger.d("TopOn原生广告视图绑定成功") + true + } catch (e: Exception) { + AdLogger.e("TopOn原生广告视图绑定失败", e) + false + } + } + + /** + * 绑定模板渲染广告 + */ + private fun bindTemplateAd( + context: Context, + container: ViewGroup, + nativeAd: NativeAd + ) { + try { + // 创建 TUNativeAdView + val nativeAdView = TUNativeAdView(context) + + // 渲染模板广告(View参数传null) + nativeAd.renderAdContainer(nativeAdView, null) + + // 准备广告(TUNativePrepareInfo参数传null) + nativeAd.prepare(nativeAdView, null) + + // 添加到容器 + container.addView(nativeAdView) + + AdLogger.d("TopOn模板渲染广告绑定完成") + } catch (e: Exception) { + AdLogger.e("TopOn模板渲染广告绑定失败", e) + throw e + } + } + + /** + * 绑定自渲染广告 + */ + private fun bindSelfRenderAd( + context: Context, + container: ViewGroup, + nativeAd: NativeAd, + style: ToponNativeAdStyle + ) { + try { + // 创建原生广告布局 + val adView = createNativeAdLayout(context, style) + + // 创建 TUNativeAdView + val nativeAdView = TUNativeAdView(context) + + // 获取广告素材 + val material = nativeAd.getAdMaterial() + + // 绑定广告数据 + bindNativeAdData(style, adView, material) + + // 创建 TUNativePrepareInfo 并绑定素材View + val prepareInfo = createPrepareInfo(adView, material) + + // 渲染广告容器 + nativeAd.renderAdContainer(nativeAdView, adView) + + // 准备广告 + nativeAd.prepare(nativeAdView, prepareInfo) + + // 添加到容器 + container.addView(nativeAdView) + + AdLogger.d("TopOn自渲染广告绑定完成") + } catch (e: Exception) { + AdLogger.e("TopOn自渲染广告绑定失败", e) + throw e + } + } + + /** + * 创建原生广告布局 + */ + private fun createNativeAdLayout( + context: Context, + style: ToponNativeAdStyle + ): ViewGroup { + return LayoutInflater.from(context) + .inflate(style.layoutResId, null) as ViewGroup + } + + /** + * 创建 TUNativePrepareInfo + */ + private fun createPrepareInfo( + adView: ViewGroup, + @Suppress("UNUSED_PARAMETER") material: TUNativeMaterial + ): com.thinkup.nativead.api.TUNativePrepareInfo { + val prepareInfo = com.thinkup.nativead.api.TUNativePrepareInfo() + prepareInfo.closeView = null + + // 绑定标题View + adView.findViewById(R.id.tv_ad_title)?.let { + prepareInfo.clickViewList.add(it) + prepareInfo.setTitleView(it) + } + + // 绑定描述View + adView.findViewById(R.id.tv_ad_description)?.let { + prepareInfo.clickViewList.add(it) + prepareInfo.descView = it + } + + // 绑定CTA按钮View + adView.findViewById(R.id.btn_ad_cta)?.let { + prepareInfo.clickViewList.add(it) + prepareInfo.ctaView = it + } + + // 绑定图标View + adView.findViewById(R.id.iv_ad_icon)?.let { + prepareInfo.clickViewList.add(it) + prepareInfo.setIconView(it) + } + + // 绑定主图View(如果有) +// adView.findViewById(R.id.iv_ad_main_image)?.let { +// prepareInfo.setMainImageView(it) +// } + + return prepareInfo + } + + /** + * 绑定原生广告数据到视图 + */ + private fun bindNativeAdData( + style: ToponNativeAdStyle, + adView: ViewGroup, + material: TUNativeMaterial + ) { + try { + val titleView = adView.findViewById(R.id.tv_ad_title) + val ctaButton = adView.findViewById(R.id.btn_ad_cta) + val iconView = adView.findViewById(R.id.iv_ad_icon) + val descView = adView.findViewById(R.id.tv_ad_description) + + // 设置广告标题 + titleView?.text = material.title ?: "Test TopOn Ads" + + // 设置CTA按钮 + ctaButton?.text = material.callToActionText ?: "INSTALL" + + // 设置广告描述 + descView?.text = material.descriptionText ?: "" + + // 设置图标 + material.iconImageUrl?.let { iconUrl -> + iconView?.let { view -> + loadImage(view.context, iconUrl, view) + view.visibility = View.VISIBLE + } + } ?: run { + iconView?.setImageResource(android.R.drawable.ic_menu_info_details) + iconView?.visibility = View.VISIBLE + } + + AdLogger.d("TopOn原生广告数据绑定完成") + + } catch (e: Exception) { + AdLogger.e("绑定TopOn原生广告数据失败", e) + } + } + + /** + * 加载图片(使用Glide) + */ + private fun loadImage(context: Context, imageUrl: String?, imageView: ImageView?) { + if (imageUrl.isNullOrEmpty() || imageView == null) { + return + } + try { + Glide.with(context) + .load(imageUrl) + .into(imageView) + } catch (e: Exception) { + AdLogger.e("加载TopOn原生广告图片失败: $imageUrl", e) + } + } + + /** + * 创建加载失败的占位视图 + */ + 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 + ) + } + } +} + diff --git a/bill/src/main/res/drawable/bg_ad_cta_button.xml b/bill/src/main/res/drawable/bg_ad_cta_button.xml new file mode 100644 index 0000000..5ee4c47 --- /dev/null +++ b/bill/src/main/res/drawable/bg_ad_cta_button.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_ad_cta_button_green.xml b/bill/src/main/res/drawable/bg_ad_cta_button_green.xml new file mode 100644 index 0000000..80fb00c --- /dev/null +++ b/bill/src/main/res/drawable/bg_ad_cta_button_green.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_ad_label_enhanced.xml b/bill/src/main/res/drawable/bg_ad_label_enhanced.xml new file mode 100644 index 0000000..d2f3d33 --- /dev/null +++ b/bill/src/main/res/drawable/bg_ad_label_enhanced.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_ad_label_enhanced_green.xml b/bill/src/main/res/drawable/bg_ad_label_enhanced_green.xml new file mode 100644 index 0000000..3cefcb1 --- /dev/null +++ b/bill/src/main/res/drawable/bg_ad_label_enhanced_green.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_ad_label_gray.xml b/bill/src/main/res/drawable/bg_ad_label_gray.xml new file mode 100644 index 0000000..9f44b13 --- /dev/null +++ b/bill/src/main/res/drawable/bg_ad_label_gray.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/bill/src/main/res/drawable/bg_button_gray_rounded.xml b/bill/src/main/res/drawable/bg_button_gray_rounded.xml new file mode 100644 index 0000000..a7f708c --- /dev/null +++ b/bill/src/main/res/drawable/bg_button_gray_rounded.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_button_rounded.xml b/bill/src/main/res/drawable/bg_button_rounded.xml new file mode 100644 index 0000000..07580de --- /dev/null +++ b/bill/src/main/res/drawable/bg_button_rounded.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/bg_native_ad_card.xml b/bill/src/main/res/drawable/bg_native_ad_card.xml new file mode 100644 index 0000000..8b395a8 --- /dev/null +++ b/bill/src/main/res/drawable/bg_native_ad_card.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/ic_full_close.xml b/bill/src/main/res/drawable/ic_full_close.xml new file mode 100644 index 0000000..89c4695 --- /dev/null +++ b/bill/src/main/res/drawable/ic_full_close.xml @@ -0,0 +1,12 @@ + + + + diff --git a/bill/src/main/res/drawable/ic_star_empty.xml b/bill/src/main/res/drawable/ic_star_empty.xml new file mode 100644 index 0000000..ec6d3b8 --- /dev/null +++ b/bill/src/main/res/drawable/ic_star_empty.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/drawable/ic_star_filled.xml b/bill/src/main/res/drawable/ic_star_filled.xml new file mode 100644 index 0000000..c90a2ee --- /dev/null +++ b/bill/src/main/res/drawable/ic_star_filled.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/bill/src/main/res/drawable/ic_star_filled_green.xml b/bill/src/main/res/drawable/ic_star_filled_green.xml new file mode 100644 index 0000000..4ab8033 --- /dev/null +++ b/bill/src/main/res/drawable/ic_star_filled_green.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/bill/src/main/res/drawable/ic_star_half.xml b/bill/src/main/res/drawable/ic_star_half.xml new file mode 100644 index 0000000..e57cb71 --- /dev/null +++ b/bill/src/main/res/drawable/ic_star_half.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/bill/src/main/res/drawable/ic_star_half_green.xml b/bill/src/main/res/drawable/ic_star_half_green.xml new file mode 100644 index 0000000..c94640e --- /dev/null +++ b/bill/src/main/res/drawable/ic_star_half_green.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/bill/src/main/res/drawable/progress_background.xml b/bill/src/main/res/drawable/progress_background.xml new file mode 100644 index 0000000..4ec035e --- /dev/null +++ b/bill/src/main/res/drawable/progress_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/bill/src/main/res/drawable/progress_custom.xml b/bill/src/main/res/drawable/progress_custom.xml new file mode 100644 index 0000000..548a35f --- /dev/null +++ b/bill/src/main/res/drawable/progress_custom.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/activity_full_screen_native_ad.xml b/bill/src/main/res/layout/activity_full_screen_native_ad.xml new file mode 100644 index 0000000..2f2ccd5 --- /dev/null +++ b/bill/src/main/res/layout/activity_full_screen_native_ad.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/activity_pangle_full_screen_native_ad.xml b/bill/src/main/res/layout/activity_pangle_full_screen_native_ad.xml new file mode 100644 index 0000000..7f28f5d --- /dev/null +++ b/bill/src/main/res/layout/activity_pangle_full_screen_native_ad.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/activity_topon_full_screen_native_ad.xml b/bill/src/main/res/layout/activity_topon_full_screen_native_ad.xml new file mode 100644 index 0000000..25acd03 --- /dev/null +++ b/bill/src/main/res/layout/activity_topon_full_screen_native_ad.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/dialog_ad_source_selection.xml b/bill/src/main/res/layout/dialog_ad_source_selection.xml new file mode 100644 index 0000000..14818b8 --- /dev/null +++ b/bill/src/main/res/layout/dialog_ad_source_selection.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/item_ad_source.xml b/bill/src/main/res/layout/item_ad_source.xml new file mode 100644 index 0000000..1dd8f9a --- /dev/null +++ b/bill/src/main/res/layout/item_ad_source.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_ad_dialog_loading.xml b/bill/src/main/res/layout/layout_ad_dialog_loading.xml new file mode 100644 index 0000000..3f16d03 --- /dev/null +++ b/bill/src/main/res/layout/layout_ad_dialog_loading.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_ad_loading.xml b/bill/src/main/res/layout/layout_ad_loading.xml new file mode 100644 index 0000000..0c353cd --- /dev/null +++ b/bill/src/main/res/layout/layout_ad_loading.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_banner_container.xml b/bill/src/main/res/layout/layout_banner_container.xml new file mode 100644 index 0000000..66396fa --- /dev/null +++ b/bill/src/main/res/layout/layout_banner_container.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_banner_loading.xml b/bill/src/main/res/layout/layout_banner_loading.xml new file mode 100644 index 0000000..b8f6a65 --- /dev/null +++ b/bill/src/main/res/layout/layout_banner_loading.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_fullscreen_loading.xml b/bill/src/main/res/layout/layout_fullscreen_loading.xml new file mode 100644 index 0000000..4f832d7 --- /dev/null +++ b/bill/src/main/res/layout/layout_fullscreen_loading.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_fullscreen_native_ad.xml b/bill/src/main/res/layout/layout_fullscreen_native_ad.xml new file mode 100644 index 0000000..7f2be28 --- /dev/null +++ b/bill/src/main/res/layout/layout_fullscreen_native_ad.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_native_ad_card.xml b/bill/src/main/res/layout/layout_native_ad_card.xml new file mode 100644 index 0000000..554e8fd --- /dev/null +++ b/bill/src/main/res/layout/layout_native_ad_card.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_native_ad_card2.xml b/bill/src/main/res/layout/layout_native_ad_card2.xml new file mode 100644 index 0000000..6d5d56d --- /dev/null +++ b/bill/src/main/res/layout/layout_native_ad_card2.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_native_ads.xml b/bill/src/main/res/layout/layout_native_ads.xml new file mode 100644 index 0000000..f909371 --- /dev/null +++ b/bill/src/main/res/layout/layout_native_ads.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bill/src/main/res/layout/layout_pangle_fullscreen_native_ad.xml b/bill/src/main/res/layout/layout_pangle_fullscreen_native_ad.xml new file mode 100644 index 0000000..7075a4e --- /dev/null +++ b/bill/src/main/res/layout/layout_pangle_fullscreen_native_ad.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_pangle_native_ads.xml b/bill/src/main/res/layout/layout_pangle_native_ads.xml new file mode 100644 index 0000000..7020228 --- /dev/null +++ b/bill/src/main/res/layout/layout_pangle_native_ads.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_pangle_native_ads_large.xml b/bill/src/main/res/layout/layout_pangle_native_ads_large.xml new file mode 100644 index 0000000..c40d13a --- /dev/null +++ b/bill/src/main/res/layout/layout_pangle_native_ads_large.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_topon_fullscreen_native_ad.xml b/bill/src/main/res/layout/layout_topon_fullscreen_native_ad.xml new file mode 100644 index 0000000..e4d1cae --- /dev/null +++ b/bill/src/main/res/layout/layout_topon_fullscreen_native_ad.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_topon_native_ads.xml b/bill/src/main/res/layout/layout_topon_native_ads.xml new file mode 100644 index 0000000..6a98644 --- /dev/null +++ b/bill/src/main/res/layout/layout_topon_native_ads.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/layout/layout_topon_native_ads_large.xml b/bill/src/main/res/layout/layout_topon_native_ads_large.xml new file mode 100644 index 0000000..b3fdaef --- /dev/null +++ b/bill/src/main/res/layout/layout_topon_native_ads_large.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bill/src/main/res/values-night/themes.xml b/bill/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..17a54dd --- /dev/null +++ b/bill/src/main/res/values-night/themes.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/bill/src/main/res/values/colors.xml b/bill/src/main/res/values/colors.xml new file mode 100644 index 0000000..e83e703 --- /dev/null +++ b/bill/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FFFFFF + #666666 + \ No newline at end of file diff --git a/bill/src/main/res/values/strings.xml b/bill/src/main/res/values/strings.xml new file mode 100644 index 0000000..18481cb --- /dev/null +++ b/bill/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + bill + Loading AD… + Loading + \ No newline at end of file diff --git a/bill/src/main/res/values/themes.xml b/bill/src/main/res/values/themes.xml new file mode 100644 index 0000000..f9bd36d --- /dev/null +++ b/bill/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/bill/src/test/java/com/remax/bill/ExampleUnitTest.kt b/bill/src/test/java/com/remax/bill/ExampleUnitTest.kt new file mode 100644 index 0000000..ff302d9 --- /dev/null +++ b/bill/src/test/java/com/remax/bill/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.remax.bill + +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) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index c7f0dc3..44379a8 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,17 @@ plugins { alias(libs.plugins.kotlin.kapt) } + + +def configFile + if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") }) { + configFile = file("app/config_release.gradle") + } else { + configFile = file("app/config_debug.gradle") // 默认使用内部测试配置 + } +apply from: configFile + + // Task to print all the module paths in the project e.g. :core:data // Used by module graph generator script tasks.register("printModulePaths") { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd738d7..8767e40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ smartRefreshLayout = "3.0.0-alpha" retrofit = "2.11.0" okhttp = "4.12.0" glide = "4.16.0" -gsonVer = "2.10.1" +gson = "2.10.1" testPoint = "0.0.1" spans = "1.1.0" multiDex = "2.0.1" @@ -52,6 +52,16 @@ annotationJvm = "1.9.1" customview = "1.2.0" navigationFragmentKtx = "2.6.0" coreKtx = "1.10.1" +coroutines = "1.10.1" +lifecycleRuntimeKtx = "2.9.2" +playServicesAds = "24.5.0" +lottie = "6.4.0" +utilcodex = "1.31.1" +roomRuntime = "2.7.2" +databindingRuntime = "8.12.1" +fragmentKtx = "1.8.9" +workRuntime = "2.9.0" +firebaseBom = "34.1.0" [libraries] @@ -93,12 +103,14 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", # glide glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } +glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" } + # multiDex multiDex = { group = "androidx.multidex:multidex", name = "multiDex", version.ref = "multiDex" } # gson -gson = { group = "com.google.code.gson:gson", name = "gson", version.ref = "gsonVer" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } # test-point test-point-annotation = { group = "io.github.zrq1060", name = "test-point-annotation", version.ref = "testPoint" } @@ -113,6 +125,27 @@ androidx-annotation-jvm = { group = "androidx.annotation", name = "annotation-jv androidx-customview = { group = "androidx.customview", name = "customview", version.ref = "customview" } androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigationFragmentKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +utilcodex = { module = "com.blankj:utilcodex", version.ref = "utilcodex" } +lottie = { group = "com.airbnb.android", name = "lottie", version.ref = "lottie" } +play-services-ads = { module = "com.google.android.gms:play-services-ads", version.ref = "playServicesAds" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } +androidx-databinding-runtime = { group = "androidx.databinding", name = "databinding-runtime", version.ref = "databindingRuntime" } +androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } +androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "workRuntime" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntime" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-config = { group = "com.google.firebase", name = "firebase-config" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } +firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } + + + [plugins] # android @@ -137,4 +170,6 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } # versions(检查依赖库版本,执行[dependencyUpdates]任务,链接:https://github.com/ben-manes/gradle-versions-plugin) versions = { id = "com.github.ben-manes.versions", version.ref = "check-versions" } # graph -moduleGraph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" } \ No newline at end of file +moduleGraph = { id = "com.jraska.module.graph.assertion", version.ref = "moduleGraph" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +room = { id = "androidx.room", version.ref = "roomRuntime" } diff --git a/settings.gradle b/settings.gradle index d6734c5..d21c55b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,13 +13,28 @@ dependencyResolutionManagement { mavenCentral() maven { url "https://jitpack.io" } maven { url 'https://s01.oss.sonatype.org/service/local/repositories/snapshots/content/' } + + maven { + url = uri("https://artifact.bytedance.com/repository/pangle/") + } + // mintegral + maven { + url = uri("https://dl-maven-android.mintegral.com/repository/mbridge_android_sdk_oversea") + } + // topon + maven { + url = uri("https://jfrog.anythinktech.com/artifactory/overseas_sdk") + } } } rootProject.name = "VidiDin-android" + apply from: "./core/core.includes.gradle" apply from: "./youtube/youtube.includes.gradle" include ':app' +include ':bill' +include ':base'