commit 9fd12c5eae06f64a0ad7c2a5dda4a4e443723b46 Author: zhaogd Date: Sun Jul 26 13:05:04 2026 +0800 初始提交:第三方合作伙伴 App 测试项目 - 支持 EPG Intent 传参启动(deviceUid + programCode 自动播放) - 代销模式 init/checkBenefits/SDK初始化/获取播放地址/播放 - Android 4.2 (API 17) 兼容 - OkHttp 3.12.x + TLS 1.2 - ExoPlayer 2.19.1 视频播放 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44b9efd --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Built files +build/ +.gradle/ +*.apk +*.aab + +# IDE +.idea/ +*.iml +local.properties + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..11e1e3e --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,104 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.pukka.mediaapp' + compileSdk 34 + + defaultConfig { + applicationId "com.pukka.mediaapp.partnertest" + minSdk 17 + targetSdk 29 + versionCode 1 + versionName "1.0" + multiDexEnabled true + + ndk { + abiFilters "armeabi-v7a", "arm64-v8a" + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { + viewBinding true + buildConfig true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main { + jniLibs.srcDirs = ['libs'] + } + } + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + resources { + excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt', 'META-INF/notice.txt', 'META-INF/ASL2.0', 'META-INF/*.kotlin_module'] + } + } +} + +dependencies { + // Kotlin & AndroidX + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.activity:activity-ktx:1.3.1' + + // multidex + implementation 'androidx.multidex:multidex:2.0.1' + + // Retrofit + OkHttp(3.12.x 兼容 API 16+) + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'com.squareup.okhttp3:logging-interceptor:3.12.12' + + // Gson + implementation 'com.google.code.gson:gson:2.10.1' + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + // JetsenxSdk + NewTVSDK + implementation files('libs/JetsenxSdk-api-future-release-v1.23.aar') + implementation files('libs/NewTVSDK-v4.0.3-app-lite.jar') + + // ExoPlayer + implementation 'com.google.android.exoplayer:exoplayer:2.19.1' + + // commons-codec(SDK 依赖) + implementation 'commons-codec:commons-codec:1.15' + + // 火山引擎 TLS 日志库(JetsenxSdk 内部依赖) + implementation('io.github.volcengine-tls:tls-android-producer:2.0.2-api16') { + exclude group: 'com.google.guava', module: 'guava' + exclude group: 'com.google.guava', module: 'guava-jdk5' + exclude group: 'com.google.guava', module: 'guava-android' + exclude group: 'com.google.guava', module: 'listenablefuture' + } + implementation 'net.jpountz.lz4:lz4:1.3.0' + implementation 'org.slf4j:slf4j-android:1.7.36' +} diff --git a/app/libs/JetsenxSdk-api-future-release-v1.23.aar b/app/libs/JetsenxSdk-api-future-release-v1.23.aar new file mode 100755 index 0000000..1d99a63 Binary files /dev/null and b/app/libs/JetsenxSdk-api-future-release-v1.23.aar differ diff --git a/app/libs/NewTVSDK-v4.0.3-app-lite.jar b/app/libs/NewTVSDK-v4.0.3-app-lite.jar new file mode 100755 index 0000000..75bc699 Binary files /dev/null and b/app/libs/NewTVSDK-v4.0.3-app-lite.jar differ diff --git a/app/libs/arm64-v8a/libnewtvsdk.so b/app/libs/arm64-v8a/libnewtvsdk.so new file mode 100755 index 0000000..705baee Binary files /dev/null and b/app/libs/arm64-v8a/libnewtvsdk.so differ diff --git a/app/libs/armeabi-v7a/libnewtvsdk.so b/app/libs/armeabi-v7a/libnewtvsdk.so new file mode 100755 index 0000000..b5460c5 Binary files /dev/null and b/app/libs/armeabi-v7a/libnewtvsdk.so differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..35b5293 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# StandardCharsets 兼容保留(Android 4.x 设备可能缺少 java.nio.charset.StandardCharsets) +-keep class java.nio.charset.StandardCharsets { *; } + +# Retrofit +-keepattributes Signature +-keepattributes *Annotation* +-keep class retrofit2.** { *; } +-keepclasseswithmembers class * { + @retrofit2.http.* ; +} + +# Gson +-keep class com.google.gson.** { *; } +-keep class com.pukka.mediaapp.model.** { *; } + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** + +# JetsenxSdk +-keep class com.jetsenxsdk.** { *; } +-keep class com.jetsenx.** { *; } + +# ExoPlayer +-keep class com.google.android.exoplayer2.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f1327e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/pukka/mediaapp/PartnerApp.kt b/app/src/main/java/com/pukka/mediaapp/PartnerApp.kt new file mode 100644 index 0000000..44fac04 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/PartnerApp.kt @@ -0,0 +1,33 @@ +package com.pukka.mediaapp + +import android.app.Application +import com.pukka.mediaapp.network.PartnerSignManager +import com.pukka.mediaapp.config.PartnerConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Application 入口 + */ +class PartnerApp : Application() { + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + PartnerSignManager.init(this) + startHeartbeat() + } + + private fun startHeartbeat() { + appScope.launch { + while (true) { + delay(PartnerConfig.HEARTBEAT_INTERVAL) + PartnerSignManager.heartbeat() + } + } + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/config/PartnerConfig.kt b/app/src/main/java/com/pukka/mediaapp/config/PartnerConfig.kt new file mode 100644 index 0000000..d12a178 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/config/PartnerConfig.kt @@ -0,0 +1,37 @@ +package com.pukka.mediaapp.config + +/** + * 第三方合作伙伴测试 App 配置 + * + * 代销模式:不传 activationCode,按设备标识查找已注册设备 + */ +object PartnerConfig { + + /** 后端地址(测试环境) */ + const val BASE_URL = "http://58.220.83.58:8084/" + + /** 鉴权头名称(单个 sign 头,由 init 下发) */ + const val SIGN_HEADER = "sign" + + /** EPG Intent Action */ + const val INTENT_ACTION = "com.pukka.mediaapp.PARTNER_LAUNCH" + + /** 轮询间隔(毫秒)- 2 秒 */ + const val POLL_INTERVAL = 2000L + + /** 轮询超时(毫秒)- 5 分钟 */ + const val POLL_TIMEOUT = 5 * 60 * 1000L + + /** 心跳间隔(毫秒)- 30 分钟 */ + const val HEARTBEAT_INTERVAL = 30 * 60 * 1000L + + /** sign 有效期(毫秒)- 24 小时 */ + const val SIGN_TTL = 24 * 60 * 60 * 1000L + + object Path { + const val INIT = "api/jst/init" + const val CHECK_BENEFITS = "api/jst/checkBenefits" + const val HEARTBEAT = "api/jst/heartBeat" + const val REFRESH_SIGN = "api/jst/refreshSign" + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/model/PartnerModels.kt b/app/src/main/java/com/pukka/mediaapp/model/PartnerModels.kt new file mode 100644 index 0000000..c89b521 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/model/PartnerModels.kt @@ -0,0 +1,75 @@ +package com.pukka.mediaapp.model + +/** + * 统一响应基础(兼容 code=0 和 code=200) + */ +open class BaseResponse( + open val code: Int = 0, + open val msg: String = "" +) { + val isSuccess: Boolean get() = code == 0 || code == 200 +} + +// ===== 1. 设备初始化 ===== + +/** + * init 请求体 + * 代销模式:activationCode 不传(null),后端按设备标识查找 + */ +data class InitRequest( + val activationCode: String? = null, + val mac: String? = null, + val wifiMac: String? = null, + val deviceSn: String? = null, + val deviceUid: String? = null +) + +/** init 响应 */ +data class InitResponse( + val sign: String = "", + val data: InitData? = null +) : BaseResponse() + +data class InitData( + val device: DeviceInfo? = null +) + +data class DeviceInfo( + val deviceId: Long = 0, + val roomNo: String? = null +) + +// ===== 2. 查询天卡权益 ===== + +data class CheckBenefitsResponse( + val data: BenefitsData? = null +) : BaseResponse() { + val status: String get() = data?.status ?: "" + val isActive: Boolean get() = status == "ACTIVE" + val thirdCardNo: String get() = data?.thirdCardNo ?: "" + val cardNo: String get() = data?.cardNo ?: "" + val expireAt: String get() = data?.expireAt ?: "" +} + +data class BenefitsData( + val status: String = "", + val thirdCardNo: String = "", + val cardNo: String = "", + val expireAt: String = "" +) + +// ===== 3. 心跳续期 ===== + +class HeartbeatResponse : BaseResponse() + +// ===== 4. 刷新 sign ===== + +data class RefreshSignResponse( + val data: RefreshSignData? = null +) : BaseResponse() { + val newSign: String get() = data?.sign ?: "" +} + +data class RefreshSignData( + val sign: String = "" +) diff --git a/app/src/main/java/com/pukka/mediaapp/network/FileLoggingInterceptor.kt b/app/src/main/java/com/pukka/mediaapp/network/FileLoggingInterceptor.kt new file mode 100644 index 0000000..909687f --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/network/FileLoggingInterceptor.kt @@ -0,0 +1,35 @@ +package com.pukka.mediaapp.network + +import com.pukka.mediaapp.utils.FileLogger +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +/** + * HTTP 文件日志拦截器 + */ +object FileLoggingInterceptor : Interceptor { + + private const val TAG = "Http" + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val url = request.url().toString() + val method = request.method() + + FileLogger.log(TAG, ">> $method $url") + + return try { + val response = chain.proceed(request) + FileLogger.log( + TAG, + "<< ${response.code()} ${response.message()} $url (耗时=${response.receivedResponseAtMillis() - response.sentRequestAtMillis()}ms)" + ) + response + } catch (e: IOException) { + FileLogger.error(TAG, "!! 请求失败 $url: ${e.javaClass.simpleName}: ${e.message}", e) + throw e + } + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/network/PartnerApiService.kt b/app/src/main/java/com/pukka/mediaapp/network/PartnerApiService.kt new file mode 100644 index 0000000..3171045 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/network/PartnerApiService.kt @@ -0,0 +1,27 @@ +package com.pukka.mediaapp.network + +import com.pukka.mediaapp.model.* +import retrofit2.http.* + +/** + * 天卡 API 接口 + * 除 init 外,所有接口由 SignInterceptor 自动注入 sign 头 + */ +interface PartnerApiService { + + /** 设备初始化(代销模式,不传 activationCode) */ + @POST("api/jst/init") + suspend fun init(@Body body: InitRequest): InitResponse + + /** 查询天卡权益 */ + @POST("api/jst/checkBenefits") + suspend fun checkBenefits(): CheckBenefitsResponse + + /** 心跳续期 */ + @POST("api/jst/heartBeat") + suspend fun heartBeat(): HeartbeatResponse + + /** 刷新 sign */ + @POST("api/jst/refreshSign") + suspend fun refreshSign(): RefreshSignResponse +} diff --git a/app/src/main/java/com/pukka/mediaapp/network/PartnerSignManager.kt b/app/src/main/java/com/pukka/mediaapp/network/PartnerSignManager.kt new file mode 100644 index 0000000..6f36b47 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/network/PartnerSignManager.kt @@ -0,0 +1,201 @@ +package com.pukka.mediaapp.network + +import android.content.Context +import android.util.Log +import com.pukka.mediaapp.config.PartnerConfig +import com.pukka.mediaapp.model.InitRequest +import com.pukka.mediaapp.model.InitResponse +import com.pukka.mediaapp.utils.DeviceInfoUtil +import com.pukka.mediaapp.utils.FileLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.ConnectionSpec +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.TlsVersion +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * 代销模式 Sign 管理器 + * + * 核心差异:不传 activationCode,后端按设备标识查找已注册设备 + */ +object PartnerSignManager { + + private const val TAG = "PartnerSignManager" + private const val PREFS_NAME = "partner_sign" + private const val KEY_SIGN = "sign" + private const val KEY_SIGN_TIME = "sign_time" + private const val KEY_DEVICE_ID = "device_id" + + private lateinit var appContext: Context + + private val _sign = MutableStateFlow("") + val sign: StateFlow = _sign.asStateFlow() + + private val _deviceId = MutableStateFlow(0L) + val deviceId: StateFlow = _deviceId.asStateFlow() + + private val _initResult = MutableStateFlow(null) + val initResult: StateFlow = _initResult.asStateFlow() + + /** Application 启动时调用 */ + fun init(context: Context) { + appContext = context.applicationContext + _sign.value = loadSign() + FileLogger.init(appContext) + FileLogger.log(TAG, "init: hasSign=${_sign.value.isNotEmpty()} baseUrl=${PartnerConfig.BASE_URL}") + } + + fun hasSign(): Boolean = _sign.value.isNotEmpty() + fun getCurrentSign(): String = _sign.value + fun getDeviceId(): Long = _deviceId.value + + /** + * 代销模式初始化:不传 activationCode,使用设备标识 + * @param overrideMac EPG 传入的 mac(可选,覆盖本地采集) + * @param overrideWifiMac EPG 传入的 wifiMac + * @param overrideDeviceSn EPG 传入的 deviceSn + * @param overrideDeviceUid EPG 传入的 deviceUid + */ + suspend fun doInit( + overrideMac: String? = null, + overrideWifiMac: String? = null, + overrideDeviceSn: String? = null, + overrideDeviceUid: String? = null + ): String { + FileLogger.log(TAG, "doInit: 开始,目标地址=${PartnerConfig.BASE_URL}${PartnerConfig.Path.INIT}") + + // 优先使用 EPG 传入的标识,没有则用本地采集 + val identity = DeviceInfoUtil.getDeviceIdentity(appContext) + val req = InitRequest( + activationCode = null, // 代销模式不传 + mac = overrideMac ?: identity.mac, + wifiMac = overrideWifiMac ?: identity.wifiMac, + deviceSn = overrideDeviceSn ?: identity.deviceSn, + deviceUid = overrideDeviceUid ?: identity.deviceUid + ) + + FileLogger.log(TAG, "doInit: 请求体 mac=${req.mac} wifiMac=${req.wifiMac} sn=${req.deviceSn} uid=${req.deviceUid}") + Log.d(TAG, "doInit: mac=${req.mac} uid=${req.deviceUid}") + + val resp = try { + PartnerRetrofitClient.apiService.init(req) + } catch (e: Exception) { + FileLogger.error(TAG, "doInit: 网络请求异常 ${e.javaClass.simpleName}: ${e.message}", e) + throw e + } + + if (resp.isSuccess && resp.sign.isNotEmpty()) { + val now = System.currentTimeMillis() + saveSign(resp.sign, now) + resp.data?.device?.deviceId?.let { + saveDeviceId(it) + _deviceId.value = it + } + _sign.value = resp.sign + _initResult.value = resp + FileLogger.log(TAG, "doInit: 成功 deviceId=${resp.data?.device?.deviceId} code=${resp.code}") + Log.d(TAG, "doInit success: deviceId=${resp.data?.device?.deviceId}") + return resp.sign + } + + FileLogger.error(TAG, "doInit: 业务失败 code=${resp.code} msg=${resp.msg}") + _initResult.value = resp + throw RuntimeException(resp.msg.ifEmpty { "设备初始化失败" }) + } + + /** 心跳续期(失败不阻断业务) */ + suspend fun heartbeat() { + if (_sign.value.isEmpty()) return + try { + PartnerRetrofitClient.apiService.heartBeat() + FileLogger.log(TAG, "heartbeat ok") + } catch (e: Exception) { + FileLogger.error(TAG, "heartbeat failed", e) + } + } + + /** 清除本地 sign */ + fun clearSign() { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() + .remove(KEY_SIGN).remove(KEY_SIGN_TIME).apply() + _sign.value = "" + } + + private fun loadSign(): String = + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_SIGN, "") ?: "" + + private fun saveSign(sign: String, time: Long) { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() + .putString(KEY_SIGN, sign).putLong(KEY_SIGN_TIME, time).apply() + } + + private fun saveDeviceId(id: Long) { + appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() + .putLong(KEY_DEVICE_ID, id).apply() + } +} + +/** + * sign 鉴权拦截器:为非 init 请求注入 sign 头 + */ +object SignInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + val original = chain.request() + val path = original.url().encodedPath() + + if (path.contains(PartnerConfig.Path.INIT)) { + return chain.proceed(original) + } + + val sign = PartnerSignManager.getCurrentSign() + val builder = original.newBuilder() + if (sign.isNotEmpty()) { + builder.addHeader(PartnerConfig.SIGN_HEADER, sign) + } + return chain.proceed(builder.build()) + } +} + +/** + * Retrofit 客户端 + */ +object PartnerRetrofitClient { + + private val okHttpClient: OkHttpClient by lazy { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + // TLS 1.2 兼容配置(Android 4.2 默认仅支持 TLS 1.0) + val tlsSpec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .build() + OkHttpClient.Builder() + .addInterceptor(SignInterceptor) + .addInterceptor(FileLoggingInterceptor) + .addInterceptor(loggingInterceptor) + .connectionSpecs(listOf(tlsSpec, ConnectionSpec.CLEARTEXT)) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + } + + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(PartnerConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + val apiService: PartnerApiService by lazy { + retrofit.create(PartnerApiService::class.java) + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt b/app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt new file mode 100644 index 0000000..8cfaf5c --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt @@ -0,0 +1,269 @@ +package com.pukka.mediaapp.sdk + +import android.content.Context +import android.text.TextUtils +import android.util.Log +import com.jetsenxsdk.api.JetsenxSdk +import com.jetsenxsdk.bean.VideoInfo +import com.jetsenxsdk.callback.JetsenxInitCallback +import com.jetsenxsdk.callback.JetsenxPricesCallback +import com.jetsenxsdk.callback.JetsenxUrlCallback +import com.pukka.mediaapp.utils.FileLogger + +/** + * JetsenxSdk Kotlin 封装 + * + * 视频地址获取流程: + * 1. init() 初始化SDK(用 thirdCardNo 作为 uid) + * 2. getAllVideoUrl() -> getAllAssetPrices(获取productCode) -> getVideoUrls/getFreeVideoUrls + */ +object JetsenxSdkUtil { + + private const val TAG = "JetsenxSdkUtil" + + // 渠道配置 + private const val AGCODE = "1100101010200492358120169" + private const val TENANT = "huashitv" + private const val ACCESSER = "JSTASJD" + private const val SECRET = "kMeWsBu0QsTXpnft" + private const val SERVICE_COMBO_CODE = "1100121010200494755850172" + private const val FALLBACK_MAC = "CC:8C:DA:C6:E9:9B" + + private var productCodeSp: String? = null + private var isInitialized = false + + interface InitCallback { + fun onSuccess(memberCode: String) + fun onFail(message: String) + } + + interface VideoUrlCallback { + fun onSuccess(videoUrl1080: String, videoUrl720: String, isFree: Boolean) + fun onError(code: String, message: String) + } + + /** + * 初始化 SDK + * @param context 上下文 + * @param uid thirdCardNo(天卡权益接口返回) + * @param mac 设备 MAC(由调用方传入,为空则从 DeviceInfoUtil 获取) + * @param callback 初始化回调 + */ + fun init(context: Context, uid: String = "test_user", mac: String? = null, callback: InitCallback? = null) { + // 优先用传入的 mac,其次从 DeviceInfoUtil 获取,最后用 FALLBACK_MAC + val finalMac = when { + !mac.isNullOrEmpty() -> mac + else -> { + val identity = com.pukka.mediaapp.utils.DeviceInfoUtil.getDeviceIdentity(context) + identity.mac ?: FALLBACK_MAC + } + } + val finalUid = if (TextUtils.isEmpty(uid)) finalMac else uid + val utype = if (finalUid.matches(Regex("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"))) "3" else "2" + + Log.d(TAG, "init: agcode=$AGCODE mac=$finalMac uid=$finalUid tenant=$TENANT utype=$utype") + FileLogger.log(TAG, "init: agcode=$AGCODE mac=$finalMac uid=$finalUid tenant=$TENANT utype=$utype") + + try { + JetsenxSdk.init( + context, AGCODE, finalMac, finalUid, + TENANT, SECRET, ACCESSER, utype, "", + SERVICE_COMBO_CODE, + object : JetsenxInitCallback { + override fun onInitSuccess(memberCode: String, p1: Boolean) { + Log.d(TAG, "onInitSuccess: memberCode=$memberCode") + FileLogger.log(TAG, "SDK init成功: memberCode=$memberCode") + isInitialized = true + callback?.onSuccess(memberCode) + } + + override fun onFail(code: String, msg: String) { + Log.e(TAG, "onInitFail: code=$code msg=$msg") + FileLogger.error(TAG, "SDK init失败: code=$code msg=$msg") + isInitialized = false + callback?.onFail(if (msg.isNotEmpty()) msg else "SDK初始化失败") + } + } + ) + } catch (e: Exception) { + Log.e(TAG, "init exception", e) + callback?.onFail("SDK初始化异常: ${e.message}") + } + } + + /** + * 获取视频播放地址 + * @param parentContentType "8"=免费, 其他=付费 + * @param programCode 节目编码 + */ + fun getAllVideoUrl( + parentContentType: String, + programCode: String, + callback: VideoUrlCallback + ) { + Log.d(TAG, "getAllVideoUrl: parentContentType=$parentContentType programCode=$programCode") + FileLogger.log(TAG, "getAllVideoUrl: parentContentType=$parentContentType programCode=$programCode") + + if (TextUtils.isEmpty(programCode)) { + callback.onError("-1", "programCode is empty") + return + } + + getAllVideoUrlNext(parentContentType, programCode, callback) + } + + private fun getAllVideoUrlNext( + parentContentType: String, + programCode: String, + callback: VideoUrlCallback + ) { + val cachedProductCode = productCodeSp + if (!cachedProductCode.isNullOrEmpty()) { + getAllVideoUrl2(parentContentType, cachedProductCode, programCode, callback) + } else { + JetsenxSdk.getAllAssetPrices(ACCESSER, SECRET, object : JetsenxPricesCallback { + override fun onAllAssetPrices(list: MutableList>?) { + FileLogger.log(TAG, "getAllAssetPrices返回: count=${list?.size}") + if (list != null && list.isNotEmpty()) { + list.forEachIndexed { index, map -> + FileLogger.log(TAG, " assetPrice[$index]: $map") + } + val productCode = list[0]["packageCode"]?.toString() ?: "" + Log.d(TAG, "onAllAssetPrices: productCode=$productCode") + FileLogger.log(TAG, "使用productCode=$productCode") + productCodeSp = productCode + getAllVideoUrl2(parentContentType, productCode, programCode, callback) + } else { + callback.onError("-2", "productCode is NULL") + } + } + + override fun onFail(code: String, msg: String) { + Log.e(TAG, "getAllAssetPrices onFail: code=$code msg=$msg") + FileLogger.error(TAG, "getAllAssetPrices失败: code=$code msg=$msg") + callback.onError(code, msg) + } + }) + } + } + + private fun getAllVideoUrl2( + parentContentType: String, + productCode: String, + programCode: String, + callback: VideoUrlCallback + ) { + if (TextUtils.isEmpty(parentContentType)) { + callback.onError("-1", "parentContentType is NULL") + return + } + if (parentContentType != "8") { + getVideoUrls2(productCode, programCode, callback) + } else { + getFreeVideoUrls2(programCode, callback) + } + } + + private fun getVideoUrls2(productCode: String, programCode: String, callback: VideoUrlCallback) { + JetsenxSdk.getVideoUrls(productCode, programCode, object : JetsenxUrlCallback { + override fun onVideoUrls(videoInfos: Array?) { + FileLogger.log(TAG, "getVideoUrls返回: count=${videoInfos?.size}") + if (videoInfos != null && videoInfos.isNotEmpty()) { + val (url1080, url720) = extractUrls(videoInfos) + if (url1080.isNotEmpty() || url720.isNotEmpty()) { + callback.onSuccess(url1080, url720, false) + } else { + callback.onError("411", "未返回播放地址") + } + } else { + callback.onError("411", "未返回播放地址") + } + } + + override fun onFail(code: String, msg: String) { + FileLogger.error(TAG, "getVideoUrls(付费)失败: code=$code msg=$msg") + if (msg.contains("未订购") || code == "-110") { + getFreeVideoUrls2(programCode, callback) + } else { + callback.onError(code, msg) + } + } + }) + } + + private fun getFreeVideoUrls2(programCode: String, callback: VideoUrlCallback) { + JetsenxSdk.getFreeVideoUrls(programCode, object : JetsenxUrlCallback { + override fun onVideoUrls(videoInfos: Array?) { + FileLogger.log(TAG, "getFreeVideoUrls返回: count=${videoInfos?.size}") + if (videoInfos != null && videoInfos.isNotEmpty()) { + val (url1080, url720) = extractUrls(videoInfos) + if (url1080.isNotEmpty() || url720.isNotEmpty()) { + callback.onSuccess(url1080, url720, true) + } else { + callback.onError("410", "未返回播放地址") + } + } else { + callback.onError("410", "未返回播放地址") + } + } + + override fun onFail(code: String, msg: String) { + FileLogger.error(TAG, "getFreeVideoUrls(免费)失败: code=$code msg=$msg") + // 免费接口失败时,回退尝试付费接口 + val cachedProductCode = productCodeSp + if (!cachedProductCode.isNullOrEmpty()) { + FileLogger.log(TAG, "免费接口失败,回退尝试付费接口 getVideoUrls") + JetsenxSdk.getVideoUrls(cachedProductCode, programCode, object : JetsenxUrlCallback { + override fun onVideoUrls(videoInfos: Array?) { + FileLogger.log(TAG, "回退getVideoUrls返回: count=${videoInfos?.size}") + if (videoInfos != null && videoInfos.isNotEmpty()) { + val (url1080, url720) = extractUrls(videoInfos) + if (url1080.isNotEmpty() || url720.isNotEmpty()) { + callback.onSuccess(url1080, url720, false) + } else { + callback.onError(code, msg) + } + } else { + callback.onError(code, msg) + } + } + + override fun onFail(code2: String, msg2: String) { + FileLogger.error(TAG, "回退getVideoUrls也失败: code=$code2 msg=$msg2") + callback.onError(code, msg) + } + }) + } else { + callback.onError(code, msg) + } + } + }) + } + + private fun extractUrls(videoInfos: Array): Pair { + var url1080 = "" + var url720 = "" + for (info in videoInfos) { + val format = info.format ?: "" + if (format.contains("720") && url720.isEmpty()) { + url720 = info.videoUrl ?: "" + } else if (format.contains("1080") && url1080.isEmpty()) { + url1080 = info.videoUrl ?: "" + } + } + FileLogger.log(TAG, "extractUrls: 1080=$url1080 720=$url720") + return Pair(url1080, url720) + } + + private fun getMacAddress(context: Context): String { + return try { + val wifiManager = context.applicationContext + .getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val wifiInfo = wifiManager.connectionInfo + val mac = wifiInfo.macAddress + if (mac.isNullOrEmpty() || mac == "02:00:00:00:00:00") "" else mac + } catch (e: Exception) { "" } + } + + fun isReady(): Boolean = isInitialized +} diff --git a/app/src/main/java/com/pukka/mediaapp/ui/BenefitsActivity.kt b/app/src/main/java/com/pukka/mediaapp/ui/BenefitsActivity.kt new file mode 100644 index 0000000..3bd2884 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/ui/BenefitsActivity.kt @@ -0,0 +1,197 @@ +package com.pukka.mediaapp.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.pukka.mediaapp.config.PartnerConfig +import com.pukka.mediaapp.databinding.ActivityBenefitsBinding +import com.pukka.mediaapp.network.PartnerRetrofitClient +import com.pukka.mediaapp.network.PartnerSignManager +import com.pukka.mediaapp.sdk.JetsenxSdkUtil +import com.pukka.mediaapp.utils.FileLogger +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * 页面2: 权益查询页 + * + * 自动轮询 checkBenefits,收到 ACTIVE 后自动初始化 SDK + */ +class BenefitsActivity : AppCompatActivity() { + + private lateinit var binding: ActivityBenefitsBinding + private var pollJob: Job? = null + private var sdkInitJob: Job? = null + private var programCode: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityBenefitsBinding.inflate(layoutInflater) + setContentView(binding.root) + + programCode = intent?.getStringExtra("programCode") + Log.d(TAG, "onCreate: programCode=$programCode") + + setupListeners() + startPolling() + } + + private fun setupListeners() { + binding.btnRetry.setOnClickListener { + startPolling() + } + + binding.btnNext.setOnClickListener { + startActivity(Intent(this, PlayTestActivity::class.java)) + } + + binding.btnBack.setOnClickListener { + finish() + } + } + + /** 开始轮询 checkBenefits */ + private fun startPolling() { + pollJob?.cancel() + binding.btnRetry.visibility = View.GONE + binding.btnNext.isEnabled = false + binding.tvStatus.text = "状态: 查询中..." + binding.tvThirdCardNo.text = "thirdCardNo: -" + binding.tvExpireAt.text = "过期时间: -" + binding.tvSdkResult.text = "SDK: 等待权益激活" + + pollJob = lifecycleScope.launch { + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < PartnerConfig.POLL_TIMEOUT) { + try { + val resp = PartnerRetrofitClient.apiService.checkBenefits() + FileLogger.log(TAG, "checkBenefits: status=${resp.status} code=${resp.code}") + + if (resp.isSuccess && resp.isActive) { + // 激活成功 + binding.tvStatus.text = "状态: ACTIVE (已激活)" + binding.tvThirdCardNo.text = "thirdCardNo: ${resp.thirdCardNo}" + binding.tvExpireAt.text = "过期时间: ${resp.expireAt}" + + // 自动初始化 SDK + initSdk(resp.thirdCardNo) + return@launch + } else if (resp.isSuccess) { + val elapsed = (System.currentTimeMillis() - startTime) / 1000 + binding.tvStatus.text = "状态: UNBOUND (激活中... ${elapsed}s)" + } else { + binding.tvStatus.text = "状态: 查询失败 code=${resp.code} msg=${resp.msg}" + if (resp.code == 401) { + // sign 失效,重新 init + FileLogger.log(TAG, "sign 失效,尝试重新 init") + PartnerSignManager.clearSign() + } + } + } catch (e: Exception) { + Log.e(TAG, "checkBenefits error", e) + FileLogger.error(TAG, "checkBenefits error: ${e.message}", e) + binding.tvStatus.text = "状态: 网络错误 ${e.message}" + } + + delay(PartnerConfig.POLL_INTERVAL) + } + + // 超时 + binding.tvStatus.text = "状态: 轮询超时(5分钟)" + binding.btnRetry.visibility = View.VISIBLE + } + } + + /** 用 thirdCardNo 初始化 SDK */ + private fun initSdk(thirdCardNo: String) { + binding.tvSdkResult.text = "SDK: 正在初始化..." + // App 自己采集 mac,不依赖 EPG 传入 + val identity = com.pukka.mediaapp.utils.DeviceInfoUtil.getDeviceIdentity(this) + val mac = identity.mac + Log.d(TAG, "initSdk: thirdCardNo=$thirdCardNo mac=$mac") + sdkInitJob = lifecycleScope.launch { + JetsenxSdkUtil.init( + context = this@BenefitsActivity, + uid = thirdCardNo, + mac = mac, + callback = object : JetsenxSdkUtil.InitCallback { + override fun onSuccess(memberCode: String) { + runOnUiThread { + binding.tvSdkResult.text = "SDK: 初始化成功\nmemberCode: $memberCode" + if (!programCode.isNullOrEmpty()) { + // EPG 传了节目编码,自动获取播放地址并播放 + autoGetUrlAndPlay(programCode!!) + } else { + binding.btnNext.isEnabled = true + Toast.makeText(this@BenefitsActivity, "SDK初始化成功,可以播放", Toast.LENGTH_SHORT).show() + } + } + } + + override fun onFail(message: String) { + runOnUiThread { + binding.tvSdkResult.text = "SDK: 初始化失败\n$message" + binding.btnRetry.visibility = View.VISIBLE + } + } + } + ) + } + } + + /** 自动获取播放地址并播放(EPG 传参模式) */ + private fun autoGetUrlAndPlay(code: String) { + binding.tvSdkResult.text = "SDK: 初始化成功,正在获取播放地址..." + Log.d(TAG, "autoGetUrlAndPlay: programCode=$code") + JetsenxSdkUtil.getAllVideoUrl( + "1", code, + object : JetsenxSdkUtil.VideoUrlCallback { + override fun onSuccess(url1080: String, url720: String, isFree: Boolean) { + runOnUiThread { + if (url1080.isNotEmpty()) { + Log.d(TAG, "autoGetUrlAndPlay: 获取成功,启动播放 1080p") + val playIntent = Intent(this@BenefitsActivity, VideoPlayerActivity::class.java) + playIntent.putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, url1080) + playIntent.putExtra(VideoPlayerActivity.EXTRA_TITLE, code) + startActivity(playIntent) + finish() // 结束中间页,播放结束后直接退出 + } else if (url720.isNotEmpty()) { + Log.d(TAG, "autoGetUrlAndPlay: 1080p为空,启动播放 720p") + val playIntent = Intent(this@BenefitsActivity, VideoPlayerActivity::class.java) + playIntent.putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, url720) + playIntent.putExtra(VideoPlayerActivity.EXTRA_TITLE, code) + startActivity(playIntent) + finish() + } else { + binding.tvSdkResult.text = "获取播放地址失败: 未返回有效地址" + binding.btnRetry.visibility = View.VISIBLE + } + } + } + + override fun onError(code: String, message: String) { + runOnUiThread { + binding.tvSdkResult.text = "获取播放地址失败: [$code] $message" + binding.btnRetry.visibility = View.VISIBLE + } + } + } + ) + } + + override fun onDestroy() { + super.onDestroy() + pollJob?.cancel() + sdkInitJob?.cancel() + } + + companion object { + private const val TAG = "BenefitsActivity" + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/ui/PartnerLaunchActivity.kt b/app/src/main/java/com/pukka/mediaapp/ui/PartnerLaunchActivity.kt new file mode 100644 index 0000000..a2720c3 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/ui/PartnerLaunchActivity.kt @@ -0,0 +1,154 @@ +package com.pukka.mediaapp.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.pukka.mediaapp.config.PartnerConfig +import com.pukka.mediaapp.databinding.ActivityPartnerLaunchBinding +import com.pukka.mediaapp.network.PartnerSignManager +import com.pukka.mediaapp.utils.DeviceInfoUtil +import kotlinx.coroutines.launch + +/** + * 页面1: 启动页/输入页 + * + * 支持两种启动方式: + * 1. EPG 传参启动(Intent action: com.pukka.mediaapp.PARTNER_LAUNCH) + * 2. 手动输入模式(调试用) + */ +class PartnerLaunchActivity : AppCompatActivity() { + + private lateinit var binding: ActivityPartnerLaunchBinding + private var fromEpg = false + private var programCode: String? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPartnerLaunchBinding.inflate(layoutInflater) + setContentView(binding.root) + + parseIntent() + setupListeners() + + // EPG 传参且带 programCode 时,自动启动完整流程 + if (fromEpg && !programCode.isNullOrEmpty()) { + binding.btnInit.postDelayed({ doInit() }, 500) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + parseIntent() + if (fromEpg && !programCode.isNullOrEmpty()) { + doInit() + } + } + + /** 解析 Intent 传参 */ + private fun parseIntent() { + val action = intent?.action ?: "" + fromEpg = action == PartnerConfig.INTENT_ACTION + + val mac = intent?.getStringExtra("mac") + val wifiMac = intent?.getStringExtra("wifiMac") + val deviceSn = intent?.getStringExtra("deviceSn") + val deviceUid = intent?.getStringExtra("deviceUid") + programCode = intent?.getStringExtra("programCode") + + Log.d(TAG, "parseIntent: action=$action fromEpg=$fromEpg mac=$mac wifiMac=$wifiMac sn=$deviceSn uid=$deviceUid programCode=$programCode") + + if (fromEpg && (!mac.isNullOrEmpty() || !deviceSn.isNullOrEmpty() || !deviceUid.isNullOrEmpty())) { + // EPG 传参模式:填充输入框 + binding.etMac.setText(mac ?: "") + binding.etWifiMac.setText(wifiMac ?: "") + binding.etDeviceSn.setText(deviceSn ?: "") + binding.etDeviceUid.setText(deviceUid ?: "") + binding.tvLaunchMode.text = if (programCode.isNullOrEmpty()) { + "启动方式: EPG 传参启动" + } else { + "启动方式: EPG 传参启动(含节目编码,将自动播放)" + } + } else { + // 手动模式:自动采集设备标识 + val identity = DeviceInfoUtil.getDeviceIdentity(this) + binding.etMac.setText(identity.mac ?: "") + binding.etWifiMac.setText(identity.wifiMac ?: "") + binding.etDeviceSn.setText(identity.deviceSn ?: "") + binding.etDeviceUid.setText(identity.deviceUid) + binding.tvLaunchMode.text = "启动方式: 手动输入模式(自动采集)" + } + } + + private fun setupListeners() { + binding.btnInit.setOnClickListener { + doInit() + } + + binding.btnAutoFill.setOnClickListener { + val identity = DeviceInfoUtil.getDeviceIdentity(this) + binding.etMac.setText(identity.mac ?: "") + binding.etWifiMac.setText(identity.wifiMac ?: "") + binding.etDeviceSn.setText(identity.deviceSn ?: "") + binding.etDeviceUid.setText(identity.deviceUid) + Toast.makeText(this, "已重新采集设备标识", Toast.LENGTH_SHORT).show() + } + } + + /** 调用 init 接口 */ + private fun doInit() { + val mac = binding.etMac.text.toString().trim() + val wifiMac = binding.etWifiMac.text.toString().trim() + val deviceSn = binding.etDeviceSn.text.toString().trim() + val deviceUid = binding.etDeviceUid.text.toString().trim() + + if (mac.isEmpty() && wifiMac.isEmpty() && deviceSn.isEmpty() && deviceUid.isEmpty()) { + Toast.makeText(this, "至少输入一个设备标识", Toast.LENGTH_SHORT).show() + return + } + + setLoading(true) + binding.tvResult.text = "正在初始化..." + + lifecycleScope.launch { + try { + val sign = PartnerSignManager.doInit( + overrideMac = mac.ifEmpty { null }, + overrideWifiMac = wifiMac.ifEmpty { null }, + overrideDeviceSn = deviceSn.ifEmpty { null }, + overrideDeviceUid = deviceUid.ifEmpty { null } + ) + val deviceId = PartnerSignManager.getDeviceId() + + binding.tvResult.text = buildString { + appendLine("初始化成功") + appendLine("sign: ${sign.take(30)}...") + appendLine("deviceId: $deviceId") + } + + // 跳转到权益查询页,传递 programCode 以支持自动播放 + val benefitsIntent = Intent(this@PartnerLaunchActivity, BenefitsActivity::class.java) + programCode?.let { benefitsIntent.putExtra("programCode", it) } + startActivity(benefitsIntent) + } catch (e: Exception) { + Log.e(TAG, "doInit failed", e) + binding.tvResult.text = "初始化失败: ${e.message}" + } finally { + setLoading(false) + } + } + } + + private fun setLoading(loading: Boolean) { + binding.btnInit.isEnabled = !loading + binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE + } + + companion object { + private const val TAG = "PartnerLaunchActivity" + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/ui/PlayTestActivity.kt b/app/src/main/java/com/pukka/mediaapp/ui/PlayTestActivity.kt new file mode 100644 index 0000000..367defb --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/ui/PlayTestActivity.kt @@ -0,0 +1,128 @@ +package com.pukka.mediaapp.ui + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.pukka.mediaapp.databinding.ActivityPlayTestBinding +import com.pukka.mediaapp.sdk.JetsenxSdkUtil +import com.pukka.mediaapp.utils.FileLogger + +/** + * 页面3: 播放测试页 + * + * 输入 programCode -> 获取播放地址 -> 播放 + */ +class PlayTestActivity : AppCompatActivity() { + + private lateinit var binding: ActivityPlayTestBinding + private var videoUrl1080: String = "" + private var videoUrl720: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPlayTestBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupListeners() + } + + private fun setupListeners() { + binding.btnGetUrl.setOnClickListener { + getVideoUrl() + } + + binding.btnPlay.setOnClickListener { + playVideo() + } + + binding.btnPlay720.setOnClickListener { + playVideo720() + } + + binding.btnBack.setOnClickListener { + finish() + } + } + + private fun getVideoUrl() { + val programCode = binding.etProgramCode.text.toString().trim() + val parentContentType = binding.etContentType.text.toString().trim().ifEmpty { "8" } + + if (programCode.isEmpty()) { + Toast.makeText(this, "请输入 programCode", Toast.LENGTH_SHORT).show() + return + } + + if (!JetsenxSdkUtil.isReady()) { + Toast.makeText(this, "SDK 未初始化,请先完成权益查询", Toast.LENGTH_SHORT).show() + return + } + + setLoading(true) + binding.tvUrlResult.text = "正在获取播放地址..." + + JetsenxSdkUtil.getAllVideoUrl( + parentContentType, + programCode, + object : JetsenxSdkUtil.VideoUrlCallback { + override fun onSuccess(url1080: String, url720: String, isFree: Boolean) { + runOnUiThread { + videoUrl1080 = url1080 + videoUrl720 = url720 + binding.tvUrlResult.text = buildString { + appendLine("获取成功 (${if (isFree) "免费" else "付费"})") + appendLine("1080p: ${url1080.ifEmpty { "无" }}") + appendLine("720p: ${url720.ifEmpty { "无" }}") + } + binding.btnPlay.isEnabled = url1080.isNotEmpty() + binding.btnPlay720.isEnabled = url720.isNotEmpty() + setLoading(false) + } + } + + override fun onError(code: String, message: String) { + runOnUiThread { + Log.e(TAG, "getVideoUrl error: code=$code msg=$message") + FileLogger.error(TAG, "getVideoUrl error: code=$code msg=$message") + binding.tvUrlResult.text = "获取失败: [$code] $message" + setLoading(false) + } + } + } + ) + } + + private fun playVideo() { + if (videoUrl1080.isEmpty()) { + Toast.makeText(this, "没有可播放的地址", Toast.LENGTH_SHORT).show() + return + } + startActivity(Intent(this, VideoPlayerActivity::class.java).apply { + putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, videoUrl1080) + putExtra(VideoPlayerActivity.EXTRA_TITLE, binding.etProgramCode.text.toString()) + }) + } + + private fun playVideo720() { + if (videoUrl720.isEmpty()) { + Toast.makeText(this, "没有 720p 地址", Toast.LENGTH_SHORT).show() + return + } + startActivity(Intent(this, VideoPlayerActivity::class.java).apply { + putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, videoUrl720) + putExtra(VideoPlayerActivity.EXTRA_TITLE, binding.etProgramCode.text.toString()) + }) + } + + private fun setLoading(loading: Boolean) { + binding.btnGetUrl.isEnabled = !loading + binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE + } + + companion object { + private const val TAG = "PlayTestActivity" + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt b/app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt new file mode 100644 index 0000000..234084c --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt @@ -0,0 +1,152 @@ +package com.pukka.mediaapp.ui + +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.WindowManager +import androidx.appcompat.app.AppCompatActivity +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.util.Util +import com.pukka.mediaapp.databinding.ActivityPlayerBinding + +/** + * 视频播放页面 - 使用 ExoPlayer + */ +class VideoPlayerActivity : AppCompatActivity() { + + private lateinit var binding: ActivityPlayerBinding + private var player: ExoPlayer? = null + private var hasError = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE) + + binding = ActivityPlayerBinding.inflate(layoutInflater) + setContentView(binding.root) + + val videoUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty() + val title = intent.getStringExtra(EXTRA_TITLE).orEmpty() + + Log.d(TAG, "onCreate: videoUrl=$videoUrl title=$title") + + binding.btnBack.setOnClickListener { finish() } + + if (videoUrl.isEmpty()) { + showError("视频地址为空") + return + } + + setupPlayer(videoUrl) + } + + private fun setupPlayer(videoUrl: String) { + showLoading(true) + val userAgent = Util.getUserAgent(this, "PartnerTestApp") + + val httpDataSourceFactory = DefaultHttpDataSource.Factory() + .setUserAgent(userAgent) + .setConnectTimeoutMs(15000) + .setReadTimeoutMs(30000) + .setAllowCrossProtocolRedirects(true) + + val dataSourceFactory = DefaultDataSource.Factory(this, httpDataSourceFactory) + + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .build() + + binding.playerView.player = player + binding.playerView.useController = true + binding.playerView.controllerAutoShow = true + binding.playerView.controllerHideOnTouch = true + binding.playerView.controllerShowTimeoutMs = 5000 + + val mediaItem = MediaItem.fromUri(Uri.parse(videoUrl)) + player?.setMediaItem(mediaItem) + player?.prepare() + player?.playWhenReady = true + player?.volume = 1f + + player?.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + when (state) { + Player.STATE_READY -> showLoading(false) + Player.STATE_BUFFERING -> showLoading(true) + Player.STATE_ENDED -> showLoading(false) + Player.STATE_IDLE -> showLoading(false) + } + } + + override fun onPlayerError(error: com.google.android.exoplayer2.PlaybackException) { + Log.e(TAG, "onPlayerError", error) + showLoading(false) + showError("播放错误: ${error.message}") + } + }) + } + + private fun showLoading(show: Boolean) { + binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun showError(message: String) { + hasError = true + showLoading(false) + binding.tvError.text = message + binding.tvError.visibility = View.VISIBLE + binding.playerView.visibility = View.GONE + } + + override fun onResume() { + super.onResume() + if (!hasError) player?.playWhenReady = true + } + + override fun onPause() { + super.onPause() + player?.playWhenReady = false + } + + override fun onStop() { + super.onStop() + player?.stop() + } + + override fun onDestroy() { + super.onDestroy() + player?.release() + player = null + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE) + } + } + + companion object { + private const val TAG = "VideoPlayerActivity" + const val EXTRA_VIDEO_URL = "extra_video_url" + const val EXTRA_TITLE = "extra_title" + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt b/app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt new file mode 100644 index 0000000..86db746 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt @@ -0,0 +1,156 @@ +package com.pukka.mediaapp.utils + +import android.content.Context +import android.os.Build +import android.provider.Settings +import android.util.Log +import java.io.BufferedReader +import java.io.FileReader +import java.io.InputStreamReader +import java.net.NetworkInterface +import java.util.UUID + +/** + * 设备标识采集工具 + * 多标识逐步降权策略,适配 Android TV 盒子 + */ +object DeviceInfoUtil { + + private const val TAG = "DeviceInfoUtil" + private const val PREFS_NAME = "device_info" + private const val KEY_UID = "uid" + + data class DeviceIdentity( + val mac: String?, + val wifiMac: String?, + val deviceSn: String?, + val deviceUid: String, + val androidId: String?, + val manufacturer: String?, + val model: String?, + val brand: String? + ) + + fun getDeviceIdentity(context: Context): DeviceIdentity { + val ethMac = getMacFromInterface("eth0") ?: getMacFromSysFile("eth0") ?: getMacFromInterface("eth") + val wifiMac = getMacFromInterface("wlan0") ?: getMacFromSysFile("wlan0") ?: getMacFromInterface("wlan") + val fallbackMac = if (ethMac == null && wifiMac == null) getFirstAvailableMac() else null + var mac = ethMac ?: fallbackMac + + val sn = getDeviceSn() + val androidId = getAndroidId(context) + val uid = getDeviceUid(context, androidId) + + if (mac == null && !androidId.isNullOrEmpty()) { + mac = generatePseudoMac(androidId) + Log.d(TAG, "真实 MAC 不可用,使用 androidId 派生伪 MAC: $mac") + } + + val identity = DeviceIdentity( + mac = mac, + wifiMac = wifiMac, + deviceSn = sn, + deviceUid = uid, + androidId = androidId, + manufacturer = Build.MANUFACTURER, + model = Build.MODEL, + brand = Build.BRAND + ) + + val macSource = if (mac != null && mac.startsWith("02:")) "pseudo" else if (mac != null) "real" else "null" + Log.d(TAG, "DeviceIdentity: mac=$mac($macSource) wifiMac=${identity.wifiMac} sn=$sn androidId=$androidId uid=$uid brand=${identity.brand} model=${identity.model}") + FileLogger.log(TAG, "DeviceIdentity: mac=$mac($macSource) wifiMac=${identity.wifiMac} sn=$sn androidId=$androidId uid=$uid manufacturer=${identity.manufacturer} model=${identity.model}") + + return identity + } + + private fun getMacFromInterface(ifacePrefix: String): String? { + return try { + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null + for (nif in interfaces) { + if (!nif.name.lowercase().startsWith(ifacePrefix)) continue + val macBytes = nif.hardwareAddress ?: continue + val mac = macBytes.joinToString(":") { "%02X".format(it) } + if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") return mac + } + null + } catch (e: Exception) { null } + } + + private fun getMacFromSysFile(iface: String): String? { + return try { + val reader = FileReader("/sys/class/net/$iface/address") + val mac = reader.readText().trim().uppercase() + reader.close() + if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") mac else null + } catch (e: Exception) { null } + } + + private fun getFirstAvailableMac(): String? { + return try { + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null + for (nif in interfaces) { + val name = nif.name.lowercase() + if (name.startsWith("lo")) continue + val macBytes = nif.hardwareAddress ?: continue + val mac = macBytes.joinToString(":") { "%02X".format(it) } + if (mac.isNotEmpty() && mac != "02:00:00:00:00:00") return mac + } + for (iface in listOf("eth0", "wlan0", "eth1", "wlan1")) { + getMacFromSysFile(iface)?.let { return it } + } + null + } catch (e: Exception) { null } + } + + private fun getDeviceSn(): String? { + val propSn = getSystemProperty("ro.serialno") + if (!propSn.isNullOrEmpty() && propSn != "unknown") return propSn + val bootSn = getSystemProperty("ro.boot.serialno") + if (!bootSn.isNullOrEmpty() && bootSn != "unknown") return bootSn + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serial = Build.getSerial() + if (!serial.isNullOrEmpty() && serial != Build.UNKNOWN) serial else null + } else { + @Suppress("DEPRECATION") + val serial = Build.SERIAL + if (!serial.isNullOrEmpty() && serial != Build.UNKNOWN) serial else null + } + } catch (e: Exception) { null } + } + + private fun getSystemProperty(propName: String): String? { + return try { + val process = Runtime.getRuntime().exec(arrayOf("getprop", propName)) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + val value = reader.readLine()?.trim() + reader.close() + process.waitFor() + value + } catch (e: Exception) { null } + } + + private fun generatePseudoMac(androidId: String): String { + val hex = androidId.take(10).padEnd(10, '0') + return ("02" + hex).chunked(2).joinToString(":") { it.uppercase() } + } + + private fun getAndroidId(context: Context): String? { + return try { + val id = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + if (id.isNullOrEmpty() || id == "9774d56d682e549c") null else id + } catch (e: Exception) { null } + } + + private fun getDeviceUid(context: Context, androidId: String?): String { + if (!androidId.isNullOrEmpty()) return androidId + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + var uid = prefs.getString(KEY_UID, null) + if (uid == null) { + uid = UUID.randomUUID().toString().replace("-", "") + prefs.edit().putString(KEY_UID, uid).apply() + } + return uid + } +} diff --git a/app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt b/app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt new file mode 100644 index 0000000..bc67fa1 --- /dev/null +++ b/app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt @@ -0,0 +1,93 @@ +package com.pukka.mediaapp.utils + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 文件日志工具 + * 日志路径:Android/data/{packageName}/files/logs/partner_test.log + */ +object FileLogger { + + private const val TAG = "FileLogger" + private const val LOG_DIR = "logs" + private const val LOG_FILE = "partner_test.log" + private const val MAX_FILE_SIZE = 2 * 1024 * 1024L + + private lateinit var appContext: Context + private val lock = Any() + + fun init(context: Context) { + appContext = context.applicationContext + log("FileLogger", "FileLogger initialized, log dir=${getLogDir()?.absolutePath}") + } + + fun log(tag: String, message: String) { + write("I", tag, message, null) + } + + fun error(tag: String, message: String, throwable: Throwable? = null) { + write("E", tag, message, throwable) + } + + private fun write(level: String, tag: String, message: String, throwable: Throwable?) { + if (!::appContext.isInitialized) return + synchronized(lock) { + try { + val dir = getLogDir() ?: return + val file = File(dir, LOG_FILE) + if (file.exists() && file.length() > MAX_FILE_SIZE) { + file.writeText("") + } + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + .format(Date()) + val sb = StringBuilder() + sb.append("$timestamp $level/$tag: $message") + throwable?.let { + sb.append("\n") + val sw = StringWriter() + it.printStackTrace(PrintWriter(sw)) + sb.append(sw.toString()) + } + sb.append("\n") + file.appendText(sb.toString()) + if (level == "E") { + Log.e(tag, message, throwable) + } else { + Log.d(tag, message) + } + } catch (e: Exception) { + Log.e(TAG, "write log failed", e) + } + } + } + + private fun getLogDir(): File? { + return try { + val dir = appContext.getExternalFilesDir(null) ?: appContext.filesDir + val logDir = File(dir, LOG_DIR) + if (!logDir.exists()) logDir.mkdirs() + logDir + } catch (e: Exception) { + Log.e(TAG, "getLogDir failed", e) + null + } + } + + fun readLog(): String { + if (!::appContext.isInitialized) return "" + return try { + val file = File(getLogDir() ?: return "", LOG_FILE) + if (file.exists()) file.readText() else "" + } catch (e: Exception) { + Log.e(TAG, "readLog failed", e) + "" + } + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..eecc216 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/layout/activity_benefits.xml b/app/src/main/res/layout/activity_benefits.xml new file mode 100644 index 0000000..e2aa556 --- /dev/null +++ b/app/src/main/res/layout/activity_benefits.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +