初始提交:第三方合作伙伴 App 测试项目

- 支持 EPG Intent 传参启动(deviceUid + programCode 自动播放)
- 代销模式 init/checkBenefits/SDK初始化/获取播放地址/播放
- Android 4.2 (API 17) 兼容
- OkHttp 3.12.x + TLS 1.2
- ExoPlayer 2.19.1 视频播放
This commit is contained in:
zhaogd
2026-07-26 13:05:04 +08:00
commit 9fd12c5eae
38 changed files with 2618 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Built files
build/
.gradle/
*.apk
*.aab
# IDE
.idea/
*.iml
local.properties
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/

104
app/build.gradle Normal file
View File

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

Binary file not shown.

Binary file not shown.

BIN
app/libs/arm64-v8a/libnewtvsdk.so Executable file

Binary file not shown.

Binary file not shown.

25
app/proguard-rules.pro vendored Normal file
View File

@@ -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.* <methods>;
}
# 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.** { *; }

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 覆盖依赖库的 minSdk 要求,兼容 Android 4.2(API 17) -->
<uses-sdk tools:overrideLibrary="
com.google.android.exoplayer2,
androidx.core,
androidx.appcompat,
com.google.android.material,
androidx.recyclerview,
androidx.activity,
androidx.lifecycle,
androidx.lifecycle.viewmodel,
androidx.lifecycle.livedata,
androidx.lifecycle.livedata.core,
androidx.lifecycle.livedata.core.ktx,
androidx.constraintlayout" />
<application
android:name=".PartnerApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PartnerTest"
android:usesCleartextTraffic="true">
<!-- 页面1: 启动页/输入页 - 支持 EPG Intent 传参启动 -->
<activity
android:name=".ui.PartnerLaunchActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden">
<!-- EPG 传参启动 -->
<intent-filter>
<action android:name="com.pukka.mediaapp.PARTNER_LAUNCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- 手动启动(调试用) -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 页面2: 权益查询页 -->
<activity
android:name=".ui.BenefitsActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
<!-- 页面3: 播放测试页 -->
<activity
android:name=".ui.PlayTestActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
<!-- 视频播放页 -->
<activity
android:name=".ui.VideoPlayerActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
</application>
</manifest>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String> = _sign.asStateFlow()
private val _deviceId = MutableStateFlow(0L)
val deviceId: StateFlow<Long> = _deviceId.asStateFlow()
private val _initResult = MutableStateFlow<InitResponse?>(null)
val initResult: StateFlow<InitResponse?> = _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)
}
}

View File

@@ -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<Map<String, Any>>?) {
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<VideoInfo>?) {
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<VideoInfo>?) {
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<VideoInfo>?) {
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<VideoInfo>): Pair<String, String> {
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF7417"
android:pathData="M27,35h54a6,6 0,0 1,6 6v32a6,6 0,0 1,-6 6h-54a6,6 0,0 1,-6 -6v-32a6,6 0,0 1,6 -6z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M48,48l16,9l-16,9z"/>
</vector>

View File

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E"
android:fillViewport="true"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="权益查询 (checkBenefits)"
android:textColor="#FFFFFF"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<!-- 状态显示 -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="状态: 准备查询..."
android:textColor="#FFAA00"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<!-- 权益信息 -->
<TextView
android:id="@+id/tvThirdCardNo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="thirdCardNo: -"
android:textColor="#00FF88"
android:textSize="13sp"
android:fontFamily="monospace"
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvExpireAt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="过期时间: -"
android:textColor="#00FF88"
android:textSize="13sp"
android:fontFamily="monospace"
android:layout_marginBottom="16dp" />
<!-- 分隔线 -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#333355"
android:layout_marginBottom="12dp" />
<!-- SDK 初始化结果 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SDK 初始化"
android:textColor="#AAAAAA"
android:textSize="14sp"
android:layout_marginBottom="6dp" />
<TextView
android:id="@+id/tvSdkResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="SDK: 等待权益激活"
android:textColor="#888888"
android:textSize="13sp"
android:fontFamily="monospace"
android:background="#0D0D1A"
android:padding="10dp"
android:layout_marginBottom="16dp" />
<!-- 按钮区 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="返回"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/btnRetry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重新查询"
android:visibility="gone"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/btnNext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="下一步: 播放测试"
android:enabled="false"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="第三方合作伙伴 App 测试"
android:textColor="#FFFFFF"
android:textSize="24sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvLaunchMode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="启动方式: -"
android:textColor="#888888"
android:textSize="14sp"
android:layout_marginBottom="24dp" />
<!-- 设备标识输入区 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="设备标识"
android:textColor="#AAAAAA"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="mac:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etMac"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="设备 MAC"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="wifiMac:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etWifiMac"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="WiFi MAC(可选)"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
<TextView
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="deviceSn:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etDeviceSn"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="设备 SN(可选)"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="deviceUid:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etDeviceUid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="设备 UID(可选)"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<!-- 按钮区 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<Button
android:id="@+id/btnAutoFill"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="重新采集"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/btnInit"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="初始化 (init)"
android:textStyle="bold" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
android:layout_marginBottom="12dp" />
<!-- 结果区 -->
<TextView
android:id="@+id/tvResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="等待初始化..."
android:textColor="#00FF88"
android:textSize="14sp"
android:fontFamily="monospace"
android:background="#0D0D1A"
android:padding="12dp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E"
android:orientation="vertical"
android:padding="24dp">
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="播放测试"
android:textColor="#FFFFFF"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="24dp" />
<!-- programCode 输入 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="140dp"
android:layout_height="wrap_content"
android:text="programCode:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etProgramCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="输入节目编码"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<!-- parentContentType 输入 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="140dp"
android:layout_height="wrap_content"
android:text="parentContentType:"
android:textColor="#CCCCCC"
android:layout_gravity="center_vertical" />
<EditText
android:id="@+id/etContentType"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="8=免费, 其他=付费"
android:text="1"
android:textColor="#FFFFFF"
android:textColorHint="#555555"
android:textSize="14sp"
android:backgroundTint="#444466"
android:singleLine="true" />
</LinearLayout>
<!-- 按钮区 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<Button
android:id="@+id/btnGetUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="获取播放地址"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/btnPlay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="播放 1080p"
android:enabled="false"
android:layout_marginEnd="12dp" />
<Button
android:id="@+id/btnPlay720"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="播放 720p"
android:enabled="false" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
android:layout_marginBottom="12dp" />
<!-- 结果区 -->
<TextView
android:id="@+id/tvUrlResult"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="等待获取播放地址..."
android:textColor="#00FF88"
android:textSize="14sp"
android:fontFamily="monospace"
android:background="#0D0D1A"
android:padding="12dp" />
<Button
android:id="@+id/btnBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="返回"
android:layout_marginTop="12dp" />
</LinearLayout>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:resize_mode="fit" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/tvError"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="#FF4444"
android:textSize="18sp"
android:visibility="gone" />
<Button
android:id="@+id/btnBack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:layout_margin="16dp"
android:text="返回"
android:alpha="0.7" />
</FrameLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_main"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg_main"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="ic_launcher_background">#1A1A2E</color>
<color name="bg_main">#1A1A2E</color>
</resources>

View File

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

View File

@@ -0,0 +1,8 @@
<resources>
<style name="Theme.PartnerTest" parent="Theme.MaterialComponents.NoActionBar">
<item name="colorPrimary">#444466</item>
<item name="colorPrimaryDark">#1A1A2E</item>
<item name="colorAccent">#00FF88</item>
<item name="android:windowBackground">#1A1A2E</item>
</style>
</resources>

4
build.gradle Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.13.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}

23
gradle.properties Executable file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# 移除 debug 应用的 TEST_ONLY 标志,避免部分 ROM 隐藏桌面图标
android.injected.testOnly=false

BIN
gradle/wrapper/gradle-wrapper.jar vendored Executable file

Binary file not shown.

6
gradle/wrapper/gradle-wrapper.properties vendored Executable file
View File

@@ -0,0 +1,6 @@
#Tue Mar 10 18:47:13 CST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Executable file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

19
settings.gradle Normal file
View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "PartnerTestApp"
include ':app'