初始化橘视厅酒店版Android项目

This commit is contained in:
freeboss
2026-07-26 12:43:59 +08:00
commit e1a088ef8e
442 changed files with 68672 additions and 0 deletions

34
.gitignore vendored Executable file
View File

@@ -0,0 +1,34 @@
# Gradle build output
build/
.gradle/
*.apk
*.ap_
*.dex
captures/
# Native build
.cxx
.externalNativeBuild
# IDE
*.iml
.idea/
.vscode/
# Local config(含 SDK 路径,不提交)
local.properties
# OS
.DS_Store
# Logs
*.log
# 签名密码(证书 jks 保留,密码文件排除)
key/keystore.properties
# 工具/文档生成
docs/*
.agents/*
.trae/*
skills-lock.json

4
MediaApp/build.gradle Normal file
View File

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

View File

@@ -0,0 +1,15 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=in-process
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false

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

Binary file not shown.

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-9.4.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
MediaApp/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
MediaApp/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

25
MediaApp/settings.gradle Normal file
View File

@@ -0,0 +1,25 @@
pluginManagement {
repositories {
// 阿里云镜像(优先)
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
// 原始源(兜底)
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
// 阿里云镜像(优先)
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/central' }
// 原始源(兜底)
google()
mavenCentral()
}
}
rootProject.name = "MediaApp"
include ':app'

View File

@@ -0,0 +1,353 @@
# MediaApp 核心业务逻辑文档
## 一、系统架构
```
┌─────────────────────────────────────────────────────────────┐
│ MediaApp (Android) │
│ │
│ HomeActivity ──→ DetailActivity ──→ VideoPlayerActivity │
│ │ │ │ │
│ │ ├─ PaymentViewModel │ │
│ │ ├─ JetsenxSdkUtil │ │
│ │ └─ DayCardApiService │ │
└───────┼───────────────┼────────────────────┘ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────┐
│ as-daycard-openapi (后端) │ │ 橘视厅/捷成平台 │
│ 端口: 8084 │ │ api.future.jetsentv.com │
│ │ │ │
│ ├─ 天卡订单管理 │ │ ├─ UMS用户系统 │
│ ├─ 天卡池管理 │ │ ├─ 卡密激活 │
│ ├─ 微信/Mock支付 │ │ ├─ VIP订购关系 │
│ ├─ 异步卡密激活 │──│──→ ├─ 视频地址分发 │
│ ├─ 权益查询(thirdCardNo) │ │ └─ 媒资管理 │
│ └─ 媒资/播放权限 │ │ │
│ │ │ │
│ 数据库: MySQL (dev-0418) │ │ │
│ 缓存: Redis │ │ │
└──────────────────────────────┘ └───────────────────────────┘
```
---
## 二、完整播放流程
### 流程图
```
用户点击播放
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 1: 检查天卡播放权限 │
│ App → GET /openapi/media/play-permission/{mediaId} │
│ ?deviceId=1 │
│ 后端: 查 day_card_pool 表 │
│ status=USED AND expire_at > NOW() │
│ │
│ ┌─ 有权限 ──→ HasPermission ──→ 跳到 Step 3 │
│ └─ 无权限 ──→ 进入支付流程 │
└─────────────────────────────────────────────────────────┘
│ (无权限)
▼
┌─────────────────────────────────────────────────────────┐
│ Step 2: 天卡支付流程 │
│ │
│ 2a. 创建订单 │
│ App → POST /openapi/daycard/create │
│ 后端: Redis防重锁 → SELECT FOR UPDATE SKIP LOCKED │
│ 抢一张UNUSED卡 → 卡状态UNUSED→RESERVED │
│ → 创建订单(CREATED) │
│ │
│ 2b. 支付 │
│ App → POST /openapi/payment/mock-pay/{orderNo} │
│ 后端: 订单→PAID, 卡→USED, expire_at=24h兜底 │
│ → @Async asyncActivateCard() │
│ │
│ 2c. 轮询支付状态 │
│ App → GET /openapi/daycard/status/{orderNo} │
│ 直到返回 PAID │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 2d: 后端异步激活卡密 (3步HTTP调用橘视厅API) │
│ │
│ ① getToken(device, thirdCardNo) │
│ POST api.future.jetsentv.com/ums_api/api/v1/ │
│ third/login │
│ 参数: uid=thirdCardNo, mac, accesser, businessLine │
│ 签名: RSA(jushitingLoginRsaPublicKey) │
│ 返回: token + memberCode │
│ │
│ ② activateCard(deviceId, cardCode, memberCode, token) │
│ POST api.future.jetsentv.com/ccms_api/hswj/api/ │
│ card/activate │
│ 参数: activateCode=cardCode, memberCode │
│ 签名: MD5(activateCode&memberCode&timestamp&key) │
│ 返回: status=true + expireTime │
│ ★ 此时橘视厅平台建立了 thirdCardNo→用户→订购关系 │
│ │
│ ③ queryVipExpireTime(thirdCardNo) [仅②未返回expireTime] │
│ POST api.future.jetsentv.com/ums_api/member/uid/ │
│ queryVipInfo │
│ 返回: vipExpireTime │
│ │
│ 激活完成: day_card_pool.activate_status=SUCCESS │
│ expire_at = 真实到期时间 │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 3: 查询天卡权益获取 thirdCardNo │
│ App → GET /openapi/daycard/benefits/{deviceId} │
│ 后端: 查 day_card_pool │
│ status=USED AND expire_at > NOW() │
│ 返回: { thirdCardNo, hasBenefits, cardCode, expireAt } │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 4: SDK初始化 (用 thirdCardNo 作为用户身份) │
│ JetsenxSdkUtil.init(ctx, thirdCardNo, callback) │
│ │
│ SDK内部: GET api.future.jetsentv.com/ums_api/openapi/ │
│ init?uid=thirdCardNo&utype=2 │
│ 参数: agcode, accesser=JSTASJD, mac, tenant=huashitv │
│ secret, serviceComboCode │
│ 返回: memberCode (用于后续视频地址获取) │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 5: 获取视频地址 │
│ JetsenxSdkUtil.getAllVideoUrl(parentContentType, │
│ programCode) │
│ │
│ SDK内部流程: │
│ 5a. getAllAssetPrices → 获取 productCode │
│ 5b. parentContentType=="8" → getFreeVideoUrls (免费) │
│ parentContentType!="8" → getVideoUrls (付费) │
│ 5c. getVideoUrls: │
│ GET api.future.jetsentv.com/bms_api/openapi/ │
│ orderAuth?memberCode&uid&productCode&programCode│
│ 有订购→返回VideoInfo[] (1080p/720p地址) │
│ 无订购→-110错误 │
│ 5d. -110时回退getFreeVideoUrls │
└─────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 6: 播放 │
│ 跳转 VideoPlayerActivity │
│ ExoPlayer 播放视频URL │
└─────────────────────────────────────────────────────────┘
```
---
## 三、防重复消费机制
### 三层防护确保不会浪费卡密
| 防护层 | 机制 | 位置 | 效果 |
|--------|------|------|------|
| 第1层 | App端 checkPermission | PaymentViewModel Step1 | 24小时内重复点击不创建订单 |
| 第2层 | 后端 Redis防重锁 | createOrder() 第1步 | 同一设备订单超时时间内只能一个订单 |
| 第3层 | 定时任务释放超时订单 | DayCardOrderTimeoutTask | 订单超时未支付,卡密回收为UNUSED |
### 权益有效期
- 天卡支付成功后获得 **24小时** 播放权益
- 期间无限次播放不额外消耗卡密
- 权益过期后再次播放需要重新支付
---
## 四、关键配置项
### 4.1 SDK配置 (JetsenxSdkUtil.kt)
| 参数 | 值 | 说明 |
|------|-----|------|
| AAR包 | `JetsenxSdk-api-future-release-v1.23.aar` | 生产域名 api.future.jetsentv.com |
| AGCODE | `1100101010200492358120169` | 渠道号 |
| TENANT | `huashitv` | 租户 |
| ACCESSER | `JSTASJD` | 合作方标识(生产环境) |
| SECRET | `kMeWsBu0QsTXpnft` | 秘钥(生产环境) |
| SERVICE_COMBO_CODE | `1100121010200494755850172` | 业务组合编码 |
### 4.2 后端橘视厅平台配置 (sys_config: JST_PLATFORM_CONFIG)
| 参数 | 值 |
|------|-----|
| jushitingBaseUrl | `https://api.future.jetsentv.com` |
| vipQueryBaseUrl | `https://api.future.jetsentv.com` |
| jushitingTenant | `huashitv` |
| jushitingAccesser | `JSTASJD` |
| jushitingBusinessLine | `4100450011381755998420012` |
| jushitingServiceComboCode | `1100121010200494755850172` |
| jushitingLoginRsaPublicKey | (RSA公钥) |
| jushitingActivateSignKey | `hhDEHY1qC4r` |
| vipQuerySignKey | `c25svtwbTyCE` |
### 4.3 thirdCardNo 生成规则
| 项目 | 规则 | 示例 |
|------|------|------|
| as-daycard-openapi | `D` + 24位随机(0-9A-Za-z) | `D00BB8E623D9280D1ACABE803` |
| as-partner-backend | 24位随机(0-9A-Za-z) | `A3f9Kp2m...`(无前缀) |
`D` 前缀确保两个项目的 thirdCardNo 空间完全隔离,不会碰撞。
---
## 五、关键文件索引
### MediaApp (Android端)
| 文件 | 作用 |
|------|------|
| `ui/DetailActivity.kt` | 详情页,播放流程入口 |
| `ui/VideoPlayerActivity.kt` | ExoPlayer播放器 |
| `ui/HomeActivity.kt` | 首页,媒资列表 |
| `sdk/JetsenxSdkUtil.kt` | SDK封装:初始化+获取视频地址 |
| `network/DayCardApiService.kt` | 天卡后端API接口定义 |
| `network/DayCardRetrofitClient.kt` | Retrofit客户端 |
| `viewmodel/PaymentViewModel.kt` | 天卡支付流程状态机 |
| `viewmodel/DetailViewModel.kt` | 详情页数据 |
| `model/daycard/DayCardModels.kt` | 天卡数据模型 |
| `config/DayCardConfig.kt` | 天卡配置(设备ID/金额) |
| `config/ApiConfig.kt` | 媒资API配置 |
### as-daycard-openapi (后端)
| 文件 | 作用 |
|------|------|
| `daycard/controller/DayCardOrderController.java` | 订单/权益接口 |
| `daycard/service/impl/DayCardOrderServiceImpl.java` | 订单核心逻辑+异步激活 |
| `daycard/service/impl/DayCardPoolServiceImpl.java` | 天卡池管理+thirdCardNo生成 |
| `daycard/task/DayCardOrderTimeoutTask.java` | 超时订单清理 |
| `jst/service/impl/JushitingApiServiceImpl.java` | 橘视厅API调用 |
| `media/service/impl/MediaServiceImpl.java` | 播放权限检查 |
| `openapi/filter/PartnerSignFilter.java` | 签名鉴权过滤器 |
| `openapi/sign/impl/PartnerSignServiceImpl.java` | HMAC-SHA256签名 |
---
## 六、项目环境信息
### 6.1 目录结构
```
/Users/zhaoguodong/Desktop/
├── jiecheng_business/ # 工作目录(本项目)
│ ├── MediaApp/ # Android客户端 (Kotlin)
│ ├── MediaApp核心业务逻辑文档.md # 本文档
│ └── README.md
├── as-daycard-openapi/ # 本项目后端 (Spring Boot, 端口8084)
│ └── src/main/resources/mapper/sys/SysConfigDao.xml # ★手动创建的MyBatis映射
└── as-partner-backend/ # 生产环境后端 (仅参考,端口8083,不在本项目中)
```
### 6.2 构建环境
| 项目 | 构建命令 | 说明 |
|------|----------|------|
| MediaApp | `JAVA_HOME=.../jbr-17.0.14 && cd MediaApp && ./gradlew assembleDebug` | APK输出: `MediaApp/app/build/outputs/apk/debug/app-debug.apk` |
| as-daycard-openapi | `cd as-daycard-openapi && mvn spring-boot:run -Dspring-boot.run.profiles=dev` | 端口8084 |
| JAVA_HOME | `/Users/zhaoguodong/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home` | JDK 17 |
| 安装到设备 | `adb install -r app-debug.apk` | |
### 6.3 后端数据库
| 项目 | 值 |
|------|-----|
| MySQL | `127.0.0.1:3306` |
| 数据库 | `dev-0418` |
| 用户/密码 | `root / r2356510` |
| mysql路径 | `/opt/homebrew/Cellar/mysql@8.0/8.0.42/bin/mysql` |
| Redis | `127.0.0.1:6379` database 0 |
### 6.4 API基础地址
| 项目 | 地址 |
|------|------|
| 天卡后端 (as-daycard-openapi) | `http://172.16.100.67:3002` (通过nginx代理到8084) |
| 橘视厅平台 | `https://api.future.jetsentv.com` |
| 媒资API | 橘视厅SDK内部调用,域名同上 |
### 6.5 测试设备
| 项目 | 值 |
|------|-----|
| deviceId | `1` (数据库device表id=1) |
| MAC | `CC:8C:DA:C6:E9:9B` |
| 测试卡密 | cardCode=`4009B1C3926DF480`, thirdCardNo=`D00BB8E623D9280D1ACABE803` |
| 卡密状态 | USED, activate_status=SUCCESS, expire_at=2026-07-14 18:23:58 |
| 合作伙伴 | partnerId=1, appKey=`71df57e7e47a4a88` |
---
## 七、API签名鉴权机制
App与天卡后端的所有 `/openapi/**` 接口(除 `/openapi/partner/**` 和 `/openapi/payment/notify`)都需要签名鉴权:
**签名格式**: `Base64URL(appKey:timestamp:nonce:HMAC-SHA256(appKey:timestamp:nonce, appSecret))`
**请求头**:
| Header | 说明 |
|--------|------|
| `X-App-Key` | appKey |
| `X-Timestamp` | 毫秒时间戳 |
| `X-Nonce` | 随机字符串 |
| `X-Sign` | 签名值 |
**校验规则**: HMAC比对 + 时间戳5分钟有效期 + Redis nonce防重放
---
## 八、PaymentViewModel 状态机
```
Idle
↓ startPaymentFlow()
CheckingPermission ──→ HasPermission ──→ fetchVideoUrlAndPlay()
↓ (无权限)
CreatingOrder ──→ OrderCreated ──→ (用户确认支付)
↓
Paying ──→ Polling ──→ PaymentSuccess ──→ fetchVideoUrlAndPlay()
↓ (任意失败) ↓
Error Error
```
**防重复跳转**: `DetailActivity.isFetchingVideoUrl` 标志位,在 `HasPermission`/`PaymentSuccess` 时置true,跳转播放器后不会因 `repeatOnLifecycle(STARTED)` 重新触发。点击播放按钮时重置为false。
---
## 九、已知限制与待办
| 项目 | 说明 |
|------|------|
| 设备ID硬编码 | `DayCardConfig.DEVICE_ID = 1L`,多设备需要改为动态获取 |
| Mock支付 | 当前用 `mock-pay` 接口测试,生产需切换微信支付 |
| 单内容测试 | 仅验证了单个媒资播放,未测试列表翻页、搜索等 |
| 签名证书 | MediaApp未配置release签名,打release包时需要配置 |
| 后端签名校验 | App端API调用需实现HMAC签名(当前可能在Retrofit拦截器中) |
---
## 十、踩坑记录
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| 详情页闪退 | onCreate中调用SDK初始化,native库加载异常 | 改为点击播放时按需初始化 |
| 16KB对齐警告 | exoplayer-ffmpeg-extension中的.so未对齐 | 移除未使用的FFmpeg扩展库 |
| NoClassDefFoundError: LogProducerConfig | 缺少火山引擎TLS日志库 | 添加tls-android-producer依赖 |
| 支付对话框闪退 | HasPermission时关闭对话框后SDK回调无法显示 | 保持对话框打开直到视频地址获取完成 |
| SDK返回-110未订购 | uid="test_user"无效 | 从benefits接口获取真实thirdCardNo |
| 后端激活失败: SysConfigDao.queryByKey not found | 缺少MyBatis XML映射 | 创建SysConfigDao.xml |
| SDK返回604 | SDK用external域名(测试),后端用api域名(生产) | 换用api-future AAR包 |
| memberCode不一致 | 两个域名指向不同服务器,用户数据不互通 | 统一使用api.future.jetsentv.com |

93
README.md Executable file
View File

@@ -0,0 +1,93 @@
项目框架说明:
libcore:包含基类、机顶盒共享参数、加密、fresco、thread,工具类和控件等
mediaplayertv:播放器
libdata:包含实体类和接口请求
app:包含页面等
环境切换:
DifferenceApi 地址修改
JcConfig 的prefix修改
AmberUtil 埋点地址修改
NewTvUtils 上线版本需要把 newtvCheckQuery里初始化失败时改为返回false
未来放通
BoxSharedParameters isNewTvPass true 放通
神策埋点上报测试环境和正式环境切换
NewTvView
release:现网环境
pre:预发布环境
演示版本APK之后打包时需要改一下产品包,正式APK不用改。演示版最新参数如下:
租户tenant:huashitv
accesser:JSTPKTEST
secret:utudfD9gVU94r52D
渠道agcode:1100101010208919998410060
业务组合comboCode:1100121010208920760580061
业务分组servicegroup:1100122010208920968780062
业务线businessLine:4100450011966871838640003
产品包productCode:2100130010417706191630003
酒店版本区别安柠和爱视
build.gradle
//酒店 ANNING 安柠 AISHI 爱视(不要手柄)
buildConfigField "String", "HOTEL_TYPE", "\"AISHI\""
新SDK JetsenxSdk-external.future-release.aar
1、build.gradle 打开注释JetsenxSdk-external.future-release
2、JetsenxSdkUtil 的init方法 打开注释
3、OrderViewModel 的createPreOrder方法 打开注释
4、JcConfig 打开火山域名的方法
神策埋点验证流程
1、NewTvView 神策初始化配置的正式环境地址修改测试环境地址
2、未来电视群里找 新 验证
//首页是否包含我的页面 默认是包含 true,不包含 false 当前只有特殊爱视酒店版本不包含 20151231
buildConfigField "boolean", "NEED_MY", "true"
}
## 构建命令 (Build Commands)
根据 `docs/CHANNEL_MANAGEMENT_SOLUTION.md` 方案及 `app/build.gradle` 配置,编译指定渠道 APK 的命令如下:
### 1. 命令格式
在项目根目录下执行:
- `gradlew assemble<FlavorName>Release`
其中 `<FlavorName>` 为渠道名称(首字母大写)。
### 2. 常用渠道编译示例
| 渠道组 | Flavor 名称 | 对应命令 | Channel ID |
| :--- | :--- | :--- | :--- |
| **通用组** | `Jctongyong` | `gradlew assembleJctongyongRelease` | `ty` |
| | `Jctongyong_market` | `gradlew assembleJctongyong_marketRelease` | `mk` |
| | `Jctongyong_projector` | `gradlew assembleJctongyong_projectorRelease` | `pj` |
| | `Jctongyong_samsung` | `gradlew assembleJctongyong_samsungRelease` | `ss` |
| | `Jctongyong_sony` | `gradlew assembleJctongyong_sonyRelease` | `sy` |
| | `Jctongyong_dbsc` | `gradlew assembleJctongyong_dbscRelease` | `db` |
| | `Jctongyong_BJDH` | `gradlew assembleJctongyong_BJDHRelease` | `bj` |
| | `Jctongyong_tcl` | `gradlew assembleJctongyong_tclRelease` | `tc` |
| | `Jctongyong_hx` | `gradlew assembleJctongyong_hxRelease` | `hx` |
| | `Jctongyong_cw` | `gradlew assembleJctongyong_cwRelease` | `cw` |
| | `Jctongyong_hw` | `gradlew assembleJctongyong_hwRelease` | `hw` |
| | `Jctongyong_al` | `gradlew assembleJctongyong_alRelease` | `al` |
| | `Jctongyong_xp` | `gradlew assembleJctongyong_xpRelease` | `xp` |
| | `Jctongyong_kangjia` | `gradlew assembleJctongyong_kangjiaRelease` | `kj` |
| **酒店组** | `JcHotel` | `gradlew assembleJcHotelRelease` | `ht` |
| | `JcHotel_aishi` | `gradlew assembleJcHotel_aishiRelease` | `as` |
| | `JcHotel_rge` | `gradlew assembleJcHotel_rgeRelease` | `rg` |
| **云游戏** | `JcCloud` | `gradlew assembleJcCloudRelease` | `cl` |
### 3. 产物路径
编译成功后 APK 位于:`app/build/outputs/apk/<flavorName>/release/`

139
app/build.gradle Normal file
View File

@@ -0,0 +1,139 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.pukka.mediaapp'
compileSdk app_android.compileSdkVersion
defaultConfig {
applicationId "com.pukka.mediaapp"
minSdk app_android.minSdkVersion
targetSdk app_android.targetSdkVersion
versionCode 1
versionName "1.0"
multiDexEnabled true
ndk {
abiFilters "armeabi-v7a", "arm64-v8a"
}
}
signingConfigs {
release {
storeFile file('../key/jiecheng.jks')
storePassword 'abc@123'
keyAlias 'key0'
keyPassword 'abc@123'
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
signingConfig signingConfigs.release
}
}
buildFeatures {
viewBinding true
buildConfig true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main {
jniLibs.srcDirs = ['libs']
}
}
packagingOptions {
// 确保 .so 文件不解压,避免对齐问题
jniLibs {
// useLegacyPackaging=true:.so 安装时解压到文件系统,由系统处理页对齐
// 绕过 Android 15 的 16KB 对齐要求(Fresco 的 .so 为 4KB 对齐)
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 {
// 基础依赖(来自根 config.gradle,与 libcore/libdata 保持一致)
app_base_dependencies.each { k, v -> implementation v }
// 模块依赖(复用 jiecheng 基础设施)
implementation project(':libcore')
implementation project(':libdata')
implementation project(':mediaplayertv')
// 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.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
// multidex(Android 4.4 兼容)
implementation 'androidx.multidex:multidex:2.0.1'
// Retrofit(okhttp 由 libcore 传递 3.12,兼容 Android 4.4)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Gson
implementation 'com.google.code.gson:gson:2.10.1'
// Glide(图片加载)
implementation 'com.github.bumptech.glide:glide:4.16.0'
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
// JetsenxSdk + NewTVSDK(使用 api-future 生产域名)
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'
// ZXing 二维码生成
implementation 'com.google.zxing:core:3.5.3'
// OAID(设备识别)
implementation 'com.github.gzu-liyujiang:Android_CN_OAID:4.1.3'
}

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.

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

@@ -0,0 +1,5 @@
# Add project specific ProGuard rules here.
-keep class com.pukka.mediaapp.model.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:name=".MediaApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MediaApp"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.ActivationActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.HomeActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".ui.ListActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".ui.SearchActivity"
android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".ui.DetailActivity"
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,41 @@
package com.pukka.mediaapp
import android.app.Application
import com.pukka.libcore.fresco.FrescoInit
import com.pukka.libcore.utils.ContextUtils
import com.pukka.mediaapp.config.AppConfiguration
import com.pukka.mediaapp.config.DayCardConfig
import com.pukka.mediaapp.network.SignManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class MediaApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
ContextUtils.init(this)
// 初始化 Fresco(TopView 等 SimpleDraweeView 依赖)
FrescoInit.initFrescoConfig()
AppConfiguration.init(this)
SignManager.init(this)
startHeartbeat()
}
/** 每 30 分钟心跳续期 sign,防止过期 */
private fun startHeartbeat() {
appScope.launch {
while (isActive) {
delay(DayCardConfig.HEARTBEAT_INTERVAL)
if (SignManager.hasSign()) {
SignManager.heartbeat()
}
}
}
}
}

View File

@@ -0,0 +1,39 @@
package com.pukka.mediaapp.config
object ApiConfig {
/** 媒资接口基础地址(现网环境) */
const val BASE_URL = "https://servhub-ep03.future.jetsentv.com/"
/** 接口路径前缀 */
const val PATH_PREFIX = "/IPTVPortalInterface"
/** 固定凭证 - sptoken */
const val SP_TOKEN = "9006874392099a89506142c6df9e3314"
/** 固定凭证 - portalcode */
const val PORTAL_CODE = "PCODE-JC"
/** 签名密钥 */
const val SIGN_KEY = "alltvjs"
/** 渠道号 */
const val CHANNEL_ID = "001"
/** 设备标识(测试用) */
const val STB_ID = "test_stb_001"
/** 接口路径 */
object Path {
const val LAYOUT_NAV = "layout/cornerLayoutNav.do"
const val LAYOUT = "layout/layout.do"
const val TOP_BUTTON = "layout/topButton.do"
const val SHORT_VIDEO = "layout/shortVideo.do"
const val CONTENT_LIST = "content/getcontentlist.do"
const val TAGS_LIST = "content/gettagslist.do"
const val SYSTEM = "content/getsystem.do"
const val SEARCH = "search/getsearchlist.do"
const val SEARCH_SUGGEST = "search/getSuggestByTSG.do"
const val FILTER = "search/filterContentList.do"
const val SERVICE_LIST = "service/getservicelist.do"
}
}

View File

@@ -0,0 +1,40 @@
package com.pukka.mediaapp.config
import android.content.Context
import com.pukka.mediaapp.utils.DeviceInfoUtil
/**
* 应用配置(从生产项目移植,适配 MediaApp)
*/
class AppConfiguration private constructor(private val appContext: Context) {
companion object {
@Volatile
private var instance: AppConfiguration? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = AppConfiguration(context.applicationContext)
}
}
}
}
fun getInstance(): AppConfiguration {
return instance ?: throw IllegalStateException("AppConfiguration not initialized")
}
}
/** 渠道标识(MediaApp 无渠道前缀,激活码为纯5位数字) */
val channelId: String = ""
/** 是否触屏模式(MediaApp 为 TV 应用,始终 false) */
val isTouchMode: Boolean = false
/** 设备唯一标识 */
fun getmDeviceUid(): String {
return DeviceInfoUtil.getDeviceIdentity(appContext).androidId ?: ""
}
}

View File

@@ -0,0 +1,45 @@
package com.pukka.mediaapp.config
/**
* 天卡 API 配置(对齐生产方案:单 sign 头鉴权)
*/
object DayCardConfig {
/** 天卡 API 基础地址 */
const val BASE_URL = "http://172.16.100.128:8084/"
/** 激活码长度(管理后台生成的 5 位激活码) */
const val ACTIVATION_CODE_LENGTH = 5
/** 鉴权头名称(单个 sign 头,由 init 下发) */
const val SIGN_HEADER = "sign"
/** 轮询间隔(毫秒) */
const val POLL_INTERVAL = 2000L
/** 轮询超时(毫秒)— 15 分钟 */
const val POLL_TIMEOUT = 900000L
/** 心跳间隔(毫秒)— 30 分钟,防止 sign 过期 */
const val HEARTBEAT_INTERVAL = 30 * 60 * 1000L
/** sign 主动刷新阈值(毫秒)— 生成 20 小时后刷新(sign 有效期 24h) */
const val SIGN_REFRESH_THRESHOLD = 20 * 60 * 60 * 1000L
/** sign 有效期(毫秒)— 24 小时 */
const val SIGN_TTL = 24 * 60 * 60 * 1000L
object Path {
/** 设备初始化(唯一不需要 sign 的接口) */
const val INIT = "api/jst/init"
/** 查询天卡权益(播放鉴权,返回 thirdCardNo) */
const val CHECK_BENEFITS = "api/jst/checkBenefits"
/** 创建天卡订单(deviceId 从 sign 解析,不传设备标识) */
const val DAYCARD_CREATE = "api/jst/daycard/create"
/** 查询订单状态 */
const val DAYCARD_STATUS = "api/jst/daycard/status/{orderNo}"
/** 心跳续期 */
const val HEARTBEAT = "api/jst/heartBeat"
/** 刷新 sign */
const val REFRESH_SIGN = "api/jst/refreshSign"
}
}

View File

@@ -0,0 +1,234 @@
package com.pukka.mediaapp.model
import com.google.gson.annotations.SerializedName
// ==================== 首页导航 (对应 GetLayoutNavBean) ====================
data class LayoutNavResponse(
val data: List<NavGroup>? = null,
val resultcode: String = "",
val message: String = "",
val nowtime: String = ""
)
data class NavGroup(
val navList: List<NavItem>? = null,
val cornerUrl: String? = null,
val cornerType: String? = null,
val navColor: String? = null
)
data class NavItem(
val navname: String = "",
val navid: String = "",
val type: Int = 0,
val sort: Int = 0,
val isFocus: String = "0",
val showType: Int = 0,
val pageCode: String = "",
val navAimage: String = "",
val navBimage: String = "",
val navFimage: String = "",
val bg_imgurl: String = "",
val game_code: String = "",
val game_id: Int = 0,
val game_name: String = ""
)
// ==================== 二级布局 (对应 GetLayoutBean) ====================
data class LayoutResponse(
val data: List<LayoutSection>? = null,
val resultcode: String = "",
val message: String = "",
val navid: String = "",
val navname: String = "",
val nowtime: String = ""
)
data class LayoutSection(
val type: String = "",
val title: String = "",
val sort: String = "",
val titleShow: String = "",
val titleicon: String = "",
val titleid: Int = 0,
val titlecode: String = "",
val bgPicture: String? = null,
val seasonCode: String? = null,
val seasonName: String? = null,
val extraData: List<ContentItem>? = null
)
// ==================== 内容列表 (对应 ContentListBean) ====================
data class ContentListResponse(
val result: List<ContentItem>? = null,
val pagetotal: Int = 0,
val sizetotal: Int = 0,
val pageindex: Int = 0,
val pagesize: Int = 0,
val resultcode: String = "",
val resultdesc: String = "",
val nowtime: String = ""
)
// ==================== 搜索结果 (对应 SearchResultBean) ====================
data class SearchResultResponse(
val result: List<ContentItem>? = null,
val pagetotal: Int = 0,
val sizetotal: Int = 0,
val pageindex: Int = 0,
val pagesize: Int = 0,
val resultcode: String = "",
val resultdesc: String = "",
val nowtime: String = ""
)
// ==================== 内容项 (统一用于布局/列表/搜索) ====================
/**
* 内容项 - 合并了 GetLayoutBean.ExtraDataBean 和 ContentListBean.ResultEntity 的字段
* 布局接口返回的字段: image, contentcode, name, isFree, cornerRightUp 等
* 列表/搜索接口返回的字段: posterimg, midimg, thumbimg, contentcode, name, score, actordisplay, description, isFree 等
*/
data class ContentItem(
val contentcode: String = "",
val code: String = "",
val name: String = "",
val type: String = "",
val title: String = "",
val image: String = "",
val focusImage: String = "",
val cornerUrl: String? = null,
val cornerRightUp: String? = null,
val cornerRightDown: String? = null,
val cornerLeftUp: String? = null,
val cornerLeftDown: String? = null,
val isFree: Int = 0,
val score: String? = null,
val actordisplay: String? = null,
val description: String? = null,
val contenttype: String = "",
val firstproject: String? = null,
val secondproject: String? = null,
val thirdproject: String? = null,
val originalcountry: String? = null,
val releaseyear: String? = null,
val posterimg: String? = null,
val midimg: String? = null,
val midimg2: String? = null,
val thumbimg: String? = null,
val cornerMarkPic: String? = null,
val duration: String? = null,
val episodecount: Int = 0,
val episodeindex: String? = null,
val parentContentType: String? = null,
val categoryname: String? = null,
val subcontents: List<SubContentItem>? = null,
val reserved1: String? = null,
val playurl: String = "",
val positionid: String = "",
val param: String = "",
val showtype: String = "",
val mediaResolution: String = "",
val mgjscode: String? = null,
val isOverlays: Int = 0
) {
/** 获取海报图片URL - 布局接口用image,列表/搜索用posterimg/midimg */
fun getPosterUrl(): String {
return when {
image.isNotEmpty() -> image
!posterimg.isNullOrEmpty() -> posterimg!!
!midimg.isNullOrEmpty() -> midimg!!
!thumbimg.isNullOrEmpty() -> thumbimg!!
!cornerRightUp.isNullOrEmpty() -> cornerRightUp!!
else -> ""
}
}
/** 获取评分 */
fun getScoreText(): String {
return score?.takeIf { it.isNotEmpty() } ?: ""
}
/** 获取演员 */
fun getActorText(): String {
return actordisplay?.takeIf { it.isNotEmpty() } ?: ""
}
/** 获取简介 */
fun getIntroText(): String {
return description?.takeIf { it.isNotEmpty() } ?: ""
}
/** 获取类型文本 */
fun getTypeText(): String {
return when {
!firstproject.isNullOrEmpty() -> firstproject!!
!originalcountry.isNullOrEmpty() -> originalcountry!!
else -> ""
}
}
/** 是否VIP内容 (isFree=0 表示收费) */
fun isVip(): Boolean = isFree == 0
/** 是否电影类型(无子集或分类名包含"电影") */
fun isFilmType(): Boolean {
if (!categoryname.isNullOrEmpty() && categoryname!!.contains("电影")) return true
return subcontents.isNullOrEmpty()
}
/** 获取SDK所需的节目编码(电影用contentcode,电视剧用第一集contentcode) */
fun getVideoCode(): String {
return if (isFilmType()) {
contentcode
} else {
subcontents?.firstOrNull()?.contentcode ?: contentcode
}
}
/** 获取SDK所需的parentContentType */
fun getSdkParentContentType(): String {
return if (isFilmType()) {
parentContentType ?: ""
} else {
subcontents?.firstOrNull()?.parentContentType ?: parentContentType ?: ""
}
}
}
/** 子集(剧集)数据 */
data class SubContentItem(
val contentcode: String = "",
val name: String = "",
val parentContentType: String? = null,
val episodeindex: String? = null,
val duration: String? = null
)
// ==================== 栏目标签 (对应 GettagslistBean) ====================
data class TagsListResponse(
val result: List<TagItem>? = null,
val pagetotal: Int = 0,
val sizetotal: Int = 0,
val pageindex: Int = 0,
val pagesize: Int = 0,
val resultcode: String = "",
val resultdesc: String = ""
)
data class TagItem(
val id: Int = 0,
val name: String = "",
val code: String = "",
val type: Int = 0,
val parent_id: Int = 0,
val big_project: String = "",
val sequence: Int = 0,
val epg_type: Int = 0,
val subject_type: Int = 0
)

View File

@@ -0,0 +1,135 @@
package com.pukka.mediaapp.model.daycard
/**
* 统一响应基础(兼容 code=0 和 code=200)
*
* 子类不再重复声明 code/msg,避免 data class 的 override 生成同名 backing field
* 导致 Gson 报 "declares multiple JSON fields named 'code'"。
* Gson 通过反射回填父类字段。
*/
open class BaseResponse(
open val code: Int = 0,
open val msg: String = ""
) {
val isSuccess: Boolean get() = code == 0 || code == 200
}
// ===== 1. 设备初始化 =====
/** init 请求体(activationCode 为管理后台生成的 5 位激活码) */
data class InitRequest(
val activationCode: String,
val mac: String?,
val wifiMac: String? = null,
val deviceSn: String?,
val deviceUid: String?,
val androidId: String? = null,
val manufacturer: String? = null,
val model: String? = null,
val brand: String? = null,
val verCode: String? = null,
val verName: String? = null
)
/** init 响应(唯一不需要 sign 的接口) */
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. 查询天卡权益(播放鉴权) =====
/** checkBenefits 响应:返回 status + thirdCardNo */
data class CheckBenefitsResponse(
val data: BenefitsData? = null
) : BaseResponse() {
val status: String get() = data?.status ?: ""
/** 是否有天卡权益(status=ACTIVE) */
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(
/** ACTIVE(有权益)/ UNBOUND(无权益) */
val status: String = "",
val thirdCardNo: String = "",
val cardNo: String = "",
val expireAt: String = ""
)
// ===== 3. 创建天卡订单 =====
/** daycard/create 响应(无需请求体,deviceId 从 sign 解析) */
data class CreateOrderResponse(
val data: CreateOrderData? = null
) : BaseResponse() {
val orderNo: String get() = data?.orderNo ?: ""
val status: String get() = data?.status ?: ""
val amount: Int get() = data?.amount ?: 0 // 金额(分)
val cardNo: String get() = data?.cardNo ?: ""
val codeUrl: String get() = data?.codeUrl ?: "" // 微信支付二维码链接
val expireAt: String get() = data?.expireAt ?: ""
val deviceId: Long get() = data?.deviceId ?: 0L
}
data class CreateOrderData(
val orderNo: String = "",
val status: String = "", // CREATED
val amount: Int = 0, // 金额(分)
val cardNo: String = "",
val codeUrl: String = "", // 微信支付二维码链接
val expireAt: String = "",
val deviceId: Long = 0
)
// ===== 4. 查询订单状态 =====
/** daycard/status/{orderNo} 响应 */
data class OrderStatusResponse(
val data: OrderStatusData? = null
) : BaseResponse() {
val status: String get() = data?.status ?: ""
val paidAt: String get() = data?.paidAt ?: ""
val codeUrl: String get() = data?.codeUrl ?: ""
val amount: Int get() = data?.amount ?: 0
val payChannel: String get() = data?.payChannel ?: ""
}
data class OrderStatusData(
val orderNo: String = "",
/** CREATED / PAYING / PAID / CANCELLED */
val status: String = "",
val paidAt: String? = null,
val codeUrl: String? = null,
val amount: Int = 0,
val payChannel: String? = null
)
// ===== 5. 心跳续期 =====
/** heartBeat 响应 */
class HeartbeatResponse : BaseResponse()
// ===== 6. 刷新 sign =====
/** refreshSign 响应 */
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,29 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.model.*
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
interface ApiService {
/** 首页导航 */
@POST
suspend fun getLayoutNav(@Url url: String, @Body params: Map<String, String>): LayoutNavResponse
/** 二级页面布局 */
@POST
suspend fun getLayout(@Url url: String, @Body params: Map<String, String>): LayoutResponse
/** 内容列表 */
@POST
suspend fun getContentList(@Url url: String, @Body params: Map<String, String>): ContentListResponse
/** 搜索 */
@POST
suspend fun search(@Url url: String, @Body params: Map<String, String>): SearchResultResponse
/** 栏目标签 */
@POST
suspend fun getTagsList(@Url url: String, @Body params: Map<String, String>): TagsListResponse
}

View File

@@ -0,0 +1,36 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.model.daycard.*
import retrofit2.http.*
/**
* 天卡 API(对齐生产方案:单 sign 头鉴权)
*
* 除 init 外,所有接口由 SignInterceptor 自动注入 sign 头。
*/
interface DayCardApiService {
/** 1. 设备初始化(唯一不需要 sign 的接口) */
@POST("api/jst/init")
suspend fun init(@Body body: InitRequest): InitResponse
/** 2. 查询天卡权益(播放鉴权,返回 thirdCardNo)。无请求体。 */
@POST("api/jst/checkBenefits")
suspend fun checkBenefits(): CheckBenefitsResponse
/** 3. 创建天卡订单(deviceId 从 sign 解析,不传设备标识)。无请求体。 */
@POST("api/jst/daycard/create")
suspend fun createOrder(): CreateOrderResponse
/** 4. 查询订单状态。status:CREATED / PAYING / PAID / CANCELLED */
@GET("api/jst/daycard/status/{orderNo}")
suspend fun getOrderStatus(@Path("orderNo") orderNo: String): OrderStatusResponse
/** 5. 心跳续期(建议每 30 分钟一次)。无请求体。 */
@POST("api/jst/heartBeat")
suspend fun heartBeat(): HeartbeatResponse
/** 6. 刷新 sign(用当前 sign 换新 sign,拦截器自动注入当前 sign)。无请求体。 */
@POST("api/jst/refreshSign")
suspend fun refreshSign(): RefreshSignResponse
}

View File

@@ -0,0 +1,347 @@
package com.pukka.mediaapp.network
import android.content.Context
import android.util.Log
import com.pukka.mediaapp.config.DayCardConfig
import com.pukka.mediaapp.model.daycard.InitRequest
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.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import java.text.SimpleDateFormat
import java.util.Locale
/**
* sign 管理器(对齐生产方案)
*
* sign 由后端 init 接口下发,App 持久化存储,24h 内复用。
* - init:设备初始化,获取 sign(唯一不需要 sign 的接口)
* - ensureSign:业务调用前确保 sign 有效,必要时自动 init / refresh
* - heartbeat:定时心跳续期
* - refresh:sign 即将过期时刷新
*/
object SignManager {
private const val TAG = "SignManager"
private const val PREFS_NAME = "jst_sign"
private const val KEY_SIGN = "sign"
private const val KEY_SIGN_TIME = "sign_time"
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_ACTIVATION_CODE = "activation_code"
private const val KEY_THIRD_CARD_NO = "third_card_no"
private const val KEY_BENEFITS_EXPIRE_AT = "benefits_expire_at"
private lateinit var appContext: Context
private val _sign = MutableStateFlow("")
val sign: StateFlow<String> = _sign.asStateFlow()
/** Application 启动时调用,加载持久化的 sign */
fun init(context: Context) {
appContext = context.applicationContext
_sign.value = loadSign()
FileLogger.init(appContext)
FileLogger.log(TAG, "init: hasSign=${_sign.value.isNotEmpty()} baseUrl=${DayCardConfig.BASE_URL}")
Log.d(TAG, "init: hasSign=${_sign.value.isNotEmpty()}")
}
fun hasSign(): Boolean = _sign.value.isNotEmpty()
fun getCurrentSign(): String = _sign.value
/** 是否已激活(有持久化的 activationCode) */
fun isActivated(): Boolean = loadActivationCode().isNotEmpty()
/** 获取已保存的激活码 */
fun getActivationCode(): String = loadActivationCode()
/** 业务调用前确保 sign 有效;为空或即将过期则自动 init/refresh */
suspend fun ensureSign(): String {
val current = _sign.value
if (current.isNotEmpty() && !needRefresh()) return current
return if (current.isNotEmpty()) {
try { refresh() } catch (e: Exception) {
FileLogger.error(TAG, "ensureSign refresh 失败,回退 init", e)
doInit()
}
} else {
doInit()
}
}
/**
* 激活设备:保存激活码并调 init 获取 sign
* 由激活码输入框调用。init 失败会清除激活码,避免下次启动误判为已激活。
*/
suspend fun activate(activationCode: String): String {
require(activationCode.isNotEmpty()) { "激活码不能为空" }
FileLogger.log(TAG, "activate: 开始激活 activationCode=$activationCode")
saveActivationCode(activationCode)
return try {
doInit()
} catch (e: Exception) {
FileLogger.error(TAG, "activate: 激活失败,清除激活码", e)
// 激活失败,清除激活码,下次启动重新弹激活框
clearActivationCode()
throw e
}
}
/** 调用 init 接口获取 sign(无需 sign 头),使用已保存的 activationCode */
suspend fun doInit(): String {
FileLogger.log(TAG, "doInit: 开始,目标地址=${DayCardConfig.BASE_URL}${DayCardConfig.Path.INIT}")
val activationCode = loadActivationCode()
if (activationCode.isEmpty()) {
FileLogger.error(TAG, "doInit: 设备未激活,请先输入激活码")
throw RuntimeException("设备未激活,请先输入激活码")
}
val identity = DeviceInfoUtil.getDeviceIdentity(appContext)
val req = InitRequest(
activationCode = activationCode,
mac = identity.mac,
wifiMac = identity.wifiMac,
deviceSn = identity.deviceSn,
deviceUid = identity.deviceUid,
androidId = identity.androidId,
manufacturer = identity.manufacturer,
model = identity.model,
brand = identity.brand
)
FileLogger.log(TAG, "doInit: 请求体 activationCode=${req.activationCode} mac=${req.mac} wifiMac=${req.wifiMac} sn=${req.deviceSn} uid=${req.deviceUid} androidId=${req.androidId} brand=${req.brand} model=${req.model}")
Log.d(TAG, "doInit: activationCode=${req.activationCode} uid=${req.deviceUid}")
val resp = try {
DayCardRetrofitClient.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) }
_sign.value = resp.sign
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}")
throw RuntimeException(resp.msg.ifEmpty { "设备初始化失败" })
}
/** 刷新 sign(用旧 sign 换新 sign) */
suspend fun refresh(): String {
val oldSign = _sign.value
if (oldSign.isEmpty()) return doInit()
// 当前 sign 由拦截器自动注入 header
val resp = try {
DayCardRetrofitClient.apiService.refreshSign()
} catch (e: Exception) {
FileLogger.error(TAG, "refresh: 网络异常,回退 init", e)
return doInit()
}
if (resp.isSuccess) {
val newSign = resp.data?.sign ?: ""
if (newSign.isNotEmpty()) {
saveSign(newSign, System.currentTimeMillis())
_sign.value = newSign
FileLogger.log(TAG, "refresh success")
Log.d(TAG, "refresh success")
return newSign
}
}
// 刷新失败,回退到 init
FileLogger.log(TAG, "refresh failed: ${resp.msg}, fallback to init")
Log.w(TAG, "refresh failed: ${resp.msg}, fallback to init")
return doInit()
}
/** 心跳续期(失败不阻断业务) */
suspend fun heartbeat() {
if (_sign.value.isEmpty()) return
try {
DayCardRetrofitClient.apiService.heartBeat()
FileLogger.log(TAG, "heartbeat ok")
Log.d(TAG, "heartbeat ok")
} catch (e: Exception) {
FileLogger.error(TAG, "heartbeat failed", e)
Log.e(TAG, "heartbeat failed", e)
}
}
/** 清除本地 sign(保留激活码,仅清 sign,用于 sign 失效重新 init) */
fun clearSign() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.remove(KEY_SIGN)
.remove(KEY_SIGN_TIME)
.apply()
_sign.value = ""
}
/** 完全重置(清除激活码 + sign,用于切换门店/重新激活) */
fun clear() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().clear().apply()
_sign.value = ""
}
/** 缓存天卡权益(thirdCardNo + 过期时间),有效期内复用,避免每次播放都鉴权 */
fun cacheBenefits(thirdCardNo: String, expireAt: String) {
val expireMs = parseExpireAtToMillis(expireAt)
val finalMs = if (expireMs > 0) expireMs else System.currentTimeMillis() + DayCardConfig.SIGN_TTL
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.putString(KEY_THIRD_CARD_NO, thirdCardNo)
.putLong(KEY_BENEFITS_EXPIRE_AT, finalMs)
.apply()
FileLogger.log(TAG, "cacheBenefits: thirdCardNo=$thirdCardNo expireAt=$expireAt -> $finalMs")
}
/** 获取缓存的 thirdCardNo(过期返回空串) */
fun getCachedThirdCardNo(): String {
val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val thirdCardNo = prefs.getString(KEY_THIRD_CARD_NO, "") ?: ""
if (thirdCardNo.isEmpty()) return ""
val expireMs = prefs.getLong(KEY_BENEFITS_EXPIRE_AT, 0L)
if (expireMs > 0 && System.currentTimeMillis() >= expireMs) {
FileLogger.log(TAG, "getCachedThirdCardNo: 权益已过期")
return ""
}
return thirdCardNo
}
/** 清除权益缓存 */
fun clearBenefits() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.remove(KEY_THIRD_CARD_NO)
.remove(KEY_BENEFITS_EXPIRE_AT)
.apply()
}
private fun parseExpireAtToMillis(expireAt: String): Long {
if (expireAt.isEmpty()) return 0L
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss"
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.getDefault())
val date = sdf.parse(expireAt)
if (date != null) return date.time
} catch (_: Exception) {
}
}
return 0L
}
/** 缓存的 deviceId(init 返回) */
fun getCachedDeviceId(): Long? {
val id = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getLong(KEY_DEVICE_ID, -1L)
return if (id > 0) id else null
}
private fun needRefresh(): Boolean {
val signTime = loadSignTime()
return signTime <= 0 || System.currentTimeMillis() - signTime >= DayCardConfig.SIGN_REFRESH_THRESHOLD
}
private fun loadSign(): String =
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_SIGN, "") ?: ""
private fun loadSignTime(): Long =
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getLong(KEY_SIGN_TIME, 0L)
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()
}
private fun loadActivationCode(): String =
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_ACTIVATION_CODE, "") ?: ""
private fun saveActivationCode(code: String) {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.putString(KEY_ACTIVATION_CODE, code)
.apply()
}
private fun clearActivationCode() {
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.remove(KEY_ACTIVATION_CODE)
.apply()
}
}
/**
* sign 鉴权拦截器:为非 init 请求注入单个 sign 头
*/
object SignInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
val original = chain.request()
val path = original.url().encodedPath()
// init 接口不需要 sign
if (path.contains(DayCardConfig.Path.INIT)) {
return chain.proceed(original)
}
val sign = SignManager.getCurrentSign()
val builder = original.newBuilder()
if (sign.isNotEmpty()) {
builder.addHeader(DayCardConfig.SIGN_HEADER, sign)
}
return chain.proceed(builder.build())
}
}
/**
* 天卡 API Retrofit 客户端(单 sign 头鉴权)
*/
object DayCardRetrofitClient {
private val okHttpClient: OkHttpClient by lazy {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
OkHttpClient.Builder()
.addInterceptor(SignInterceptor)
.addInterceptor(FileLoggingInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(DayCardConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val apiService: DayCardApiService by lazy {
retrofit.create(DayCardApiService::class.java)
}
}

View File

@@ -0,0 +1,43 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.utils.FileLogger
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
/**
* HTTP 文件日志拦截器
*
* 记录请求 URL、响应码及网络异常到日志文件,
* 用于诊断「请求无法到达后端」类问题。
*/
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) {
// 网络层异常:连接超时、DNS 解析失败、连接拒绝等
FileLogger.error(
TAG,
"!! 请求失败 $url: ${e.javaClass.simpleName}: ${e.message}",
e
)
throw e
}
}
}

View File

@@ -0,0 +1,122 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.config.ApiConfig
import com.pukka.mediaapp.model.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 媒资数据仓库 - 封装所有API调用
*/
class MediaRepository {
private val api = RetrofitClient.apiService
/** 获取首页导航 */
suspend fun getLayoutNav(): Result<LayoutNavResponse> = withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams("stbid" to ApiConfig.STB_ID)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.LAYOUT_NAV
val response = api.getLayoutNav(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
/** 获取二级页面布局 */
suspend fun getLayout(navid: String): Result<LayoutResponse> = withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams("navid" to navid)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.LAYOUT
val response = api.getLayout(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
/** 获取内容列表 */
suspend fun getContentList(
catalogid: String,
pageindex: Int = 1,
pagesize: Int = 20
): Result<ContentListResponse> = withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams(
"catalogid" to catalogid,
"pageindex" to pageindex.toString(),
"pagesize" to pagesize.toString()
)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.CONTENT_LIST
val response = api.getContentList(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
/** 获取内容详情 */
suspend fun getContentDetail(contentcode: String): Result<ContentListResponse> =
withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams(
"contentcode" to contentcode,
"need4k" to "2",
"needDrm" to "2"
)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.CONTENT_LIST
val response = api.getContentList(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
/** 搜索 */
suspend fun search(
keyword: String,
pageindex: Int = 1,
pagesize: Int = 20
): Result<SearchResultResponse> = withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams(
"searchvalue" to keyword,
"pageindex" to pageindex.toString(),
"pagesize" to pagesize.toString(),
"firstproject" to ""
)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.SEARCH
val response = api.search(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
/** 获取栏目标签 */
suspend fun getTagsList(parentid: String): Result<TagsListResponse> = withContext(Dispatchers.IO) {
try {
val params = SignUtil.buildParams(
"type" to "1",
"parentid" to parentid
)
val url = ApiConfig.BASE_URL + ApiConfig.PATH_PREFIX + "/" + ApiConfig.Path.TAGS_LIST
val response = api.getTagsList(url, params)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
companion object {
@Volatile
private var instance: MediaRepository? = null
fun getInstance(): MediaRepository {
return instance ?: synchronized(this) {
instance ?: MediaRepository().also { instance = it }
}
}
}
}

View File

@@ -0,0 +1,36 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.config.ApiConfig
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private val okHttpClient: OkHttpClient by lazy {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
OkHttpClient.Builder()
.addInterceptor(FileLoggingInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(ApiConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}

View File

@@ -0,0 +1,48 @@
package com.pukka.mediaapp.network
import com.pukka.mediaapp.config.ApiConfig
import java.security.MessageDigest
/**
* 媒资接口签名工具
*
* 签名算法: sign = MD5(sptoken + timeshift + portalcode + KEY).toUpperCase()
*/
object SignUtil {
/**
* 生成公共参数(包含签名)
* @return Map 包含 timeshift, sign, portalcode, sptoken, channelid
*/
fun buildCommonParams(): MutableMap<String, String> {
val timeshift = System.currentTimeMillis().toString()
val sign = md5Upper("${ApiConfig.SP_TOKEN}${timeshift}${ApiConfig.PORTAL_CODE}${ApiConfig.SIGN_KEY}")
return mutableMapOf(
"timeshift" to timeshift,
"sign" to sign,
"portalcode" to ApiConfig.PORTAL_CODE,
"sptoken" to ApiConfig.SP_TOKEN,
"channelid" to ApiConfig.CHANNEL_ID
)
}
/**
* 构建完整请求参数(公共参数 + 业务参数)
*/
fun buildParams(vararg pairs: Pair<String, String>): Map<String, String> {
val params = buildCommonParams()
pairs.forEach { (key, value) ->
params[key] = value
}
return params
}
/**
* MD5 大写
*/
private fun md5Upper(input: String): String {
val md = MessageDigest.getInstance("MD5")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return digest.joinToString("") { "%02X".format(it) }
}
}

View File

@@ -0,0 +1,282 @@
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封装 - 从生产环境JetsenxSdkUtil.java移植
*
* 视频地址获取流程:
* 1. init() 初始化SDK
* 2. getAllVideoUrl() → getAllAssetPrices(获取productCode) → getVideoUrls/getFreeVideoUrls
* 3. 根据parentContentType=="8"判断免费/付费
*/
object JetsenxSdkUtil {
private const val TAG = "JetsenxSdkUtil"
// 渠道配置 (debug测试环境 - 与生产环境ChannelManger.initChannel()一致)
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"
// MAC保底值
private const val FALLBACK_MAC = "CC:8C:DA:C6:E9:9B"
private var productCodeSp: String? = null
private var isInitialized = false
/** SDK初始化回调 */
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 callback 初始化回调
*/
fun init(context: Context, uid: String = "test_user", callback: InitCallback? = null) {
val mac = getMacAddress(context)
val finalMac = if (TextUtils.isEmpty(mac)) FALLBACK_MAC else mac
val finalUid = if (TextUtils.isEmpty(uid)) finalMac else uid
// 判断uid是否为MAC地址格式
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 节目编码
* @param callback 回调
*/
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 {
// 获取productCode
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
) {
Log.d(TAG, "getAllVideoUrl2: parentContentType=$parentContentType productCode=$productCode")
FileLogger.log(TAG, "getAllVideoUrl2: parentContentType=$parentContentType productCode=$productCode 付费=${parentContentType != "8"}")
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
) {
Log.d(TAG, "getVideoUrls2: productCode=$productCode programCode=$programCode")
JetsenxSdk.getVideoUrls(productCode, programCode, object : JetsenxUrlCallback {
override fun onVideoUrls(videoInfos: Array<VideoInfo>?) {
Log.d(TAG, "getVideoUrls onVideoUrls: count=${videoInfos?.size}")
FileLogger.log(TAG, "getVideoUrls返回: count=${videoInfos?.size}")
if (videoInfos != null) {
videoInfos.forEachIndexed { index, info ->
FileLogger.log(TAG, " VideoInfo[$index]: format=${info.format} videoUrl=${info.videoUrl}")
}
}
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) {
Log.e(TAG, "getVideoUrls onFail: code=$code msg=$msg")
FileLogger.error(TAG, "getVideoUrls(付费)失败: code=$code msg=$msg")
// "未订购"时,尝试用免费接口获取(某些内容可能实际可免费播放)
if (msg.contains("未订购") || code == "-110") {
Log.d(TAG, "未订购,尝试getFreeVideoUrls回退")
getFreeVideoUrls2(programCode, callback)
} else {
callback.onError(code, msg)
}
}
})
}
/** 获取免费内容播放地址 */
private fun getFreeVideoUrls2(
programCode: String,
callback: VideoUrlCallback
) {
Log.d(TAG, "getFreeVideoUrls2: programCode=$programCode")
JetsenxSdk.getFreeVideoUrls(programCode, object : JetsenxUrlCallback {
override fun onVideoUrls(videoInfos: Array<VideoInfo>?) {
Log.d(TAG, "getFreeVideoUrls onVideoUrls: count=${videoInfos?.size}")
FileLogger.log(TAG, "getFreeVideoUrls返回: count=${videoInfos?.size}")
if (videoInfos != null) {
videoInfos.forEachIndexed { index, info ->
FileLogger.log(TAG, " VideoInfo[$index]: format=${info.format} videoUrl=${info.videoUrl}")
}
}
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) {
Log.e(TAG, "getFreeVideoUrls onFail: code=$code msg=$msg")
FileLogger.error(TAG, "getFreeVideoUrls(免费)失败: code=$code msg=$msg")
callback.onError(code, msg)
}
})
}
/** 从VideoInfo数组中提取1080p和720p地址 */
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 ?: ""
}
}
Log.d(TAG, "extractUrls: 1080=$url1080 720=$url720")
FileLogger.log(TAG, "extractUrls: 1080=$url1080 720=$url720")
return Pair(url1080, url720)
}
/** 获取设备MAC地址 */
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,63 @@
package com.pukka.mediaapp.ui
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.pukka.mediaapp.databinding.ActivityActivationBinding
import com.pukka.mediaapp.network.SignManager
import com.pukka.mediaapp.utils.FileLogger
import com.pukka.mediaapp.widget.DeviceNotActivatedView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 激活码输入页(完整移植生产项目 DeviceNotActivatedView)
*
* - 三栏布局:设备信息 | 码框预览 | 数字键盘
* - 输满激活码自动提交,或手动提交
* - 激活成功跳转 HomeActivity
*/
class ActivationActivity : AppCompatActivity() {
private lateinit var binding: ActivityActivationBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 已激活则直接进首页
if (SignManager.isActivated() && SignManager.hasSign()) {
startActivity(Intent(this, HomeActivity::class.java))
finish()
return
}
binding = ActivityActivationBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.activationView.setOnActivateListener(object : DeviceNotActivatedView.OnActivateListener {
override fun onSubmit(activateCode: String, roomNo: String) {
submitActivation(activateCode)
}
})
}
private fun submitActivation(code: String) {
FileLogger.log("ActivationActivity", "提交激活码: $code")
binding.activationView.showActivateMessage("正在激活...")
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { SignManager.activate(code) }
FileLogger.log("ActivationActivity", "激活成功")
binding.activationView.showActivateMessage("激活成功")
startActivity(Intent(this@ActivationActivity, HomeActivity::class.java))
finish()
} catch (e: Exception) {
FileLogger.error("ActivationActivity", "激活失败: ${e.message}", e)
binding.activationView.showActivateErrorMessage("激活失败:${e.message}")
}
}
}
}

View File

@@ -0,0 +1,544 @@
package com.pukka.mediaapp.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumptech.glide.Glide
import com.pukka.mediaapp.databinding.ActivityDetailBinding
import com.pukka.mediaapp.databinding.DialogPaymentBinding
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.network.SignManager
import com.pukka.mediaapp.sdk.JetsenxSdkUtil
import com.pukka.mediaapp.ui.adapter.AppColors
import com.pukka.mediaapp.utils.QRCodeUtil
import com.pukka.mediaapp.utils.FileLogger
import com.pukka.mediaapp.viewmodel.DetailViewModel
import com.pukka.mediaapp.viewmodel.PaymentState
import com.pukka.mediaapp.viewmodel.PaymentViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.Locale
/**
* 详情页:海报、标题、评分、演员、简介 + 播放(含微信扫码支付流程)+ 返回。
*
* 完整播放流程(改造后):
* 1. 点击播放 → 采集设备标识 → 检查天卡播放权限
* 2. 有权限 → 调用SDK获取视频地址 → 跳转播放器
* 3. 无权限 → 创建订单 → 展示微信支付二维码 → 轮询支付状态
* 4. 支付成功 → 获取权益 → 初始化SDK → 获取视频地址 → 跳转播放器
* 5. 超时/关闭 → 展示重新生成按钮
*/
class DetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityDetailBinding
private val viewModel: DetailViewModel by viewModels()
private val paymentViewModel: PaymentViewModel by viewModels()
private var paymentDialog: AlertDialog? = null
private var dialogBinding: DialogPaymentBinding? = null
private var contentItem: ContentItem? = null
private var sdkInitialized = false
private var isFetchingVideoUrl = false // 防止重复跳转播放器
private var currentMediaId: String = ""
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
val contentcode = intent.getStringExtra(EXTRA_CONTENTCODE).orEmpty()
val name = intent.getStringExtra(EXTRA_NAME).orEmpty()
currentMediaId = contentcode
binding.tvTitle.text = name
applyTextColors()
binding.btnBack.setOnClickListener { finish() }
binding.btnPlay.setOnClickListener {
Log.d("DetailActivity", "btnPlay clicked! contentcode=$contentcode")
isFetchingVideoUrl = false // 重置标志
startPaymentFlow()
}
observeViewModel()
observePaymentState()
viewModel.loadDetail(contentcode)
}
/** 开始鉴权/支付流程(sign 由 SignManager 管理,无需 App 传设备标识) */
private fun startPaymentFlow() {
// 取消之前的倒计时
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.startPaymentFlow()
}
private fun applyTextColors() {
binding.tvTitle.setTextColor(AppColors.TEXT_PRIMARY)
binding.tvActor.setTextColor(AppColors.TEXT_SECONDARY)
binding.tvIntro.setTextColor(AppColors.TEXT_TERTIARY)
}
/** 时长格式化:秒 -> "104分钟" 或 "1小时44分钟" */
private fun formatDuration(duration: String?): String? {
if (duration.isNullOrEmpty()) return null
val seconds = duration.toLongOrNull() ?: return null
if (seconds <= 0) return null
val totalMinutes = seconds / 60
if (totalMinutes < 60) return "${totalMinutes}分钟"
val hours = totalMinutes / 60
val mins = totalMinutes % 60
return if (mins > 0) "${hours}小时${mins}分钟" else "${hours}小时"
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.detail.collect { item ->
if (item == null) return@collect
contentItem = item
binding.tvTitle.text = item.name
// 元信息行:分类 · 地区 · 年份 · 时长
val metaParts = mutableListOf<String>()
item.firstproject?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.secondproject?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.originalcountry?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
item.releaseyear?.takeIf { it.isNotEmpty() }?.let { metaParts.add(it) }
formatDuration(item.duration)?.let { metaParts.add(it) }
binding.tvType.text = metaParts.joinToString(" · ")
// 演员:| 分割取前4个
val actors = item.actordisplay?.split("|")?.map { it.trim() }?.filter { it.isNotEmpty() }
binding.tvActor.text = if (!actors.isNullOrEmpty()) "主演: ${actors.take(4).joinToString(" ")}" else ""
binding.tvIntro.text = item.getIntroText()
// 横幅背景图
val backdrop = item.thumbimg?.takeIf { it.isNotEmpty() }
?: item.midimg?.takeIf { it.isNotEmpty() }
?: item.image?.takeIf { it.isNotEmpty() }
if (!backdrop.isNullOrEmpty()) {
Glide.with(this@DetailActivity).load(backdrop).into(binding.ivBackdrop)
}
// 海报
val poster = item.posterimg?.takeIf { it.isNotEmpty() }
?: item.getPosterUrl()
Glide.with(this@DetailActivity).load(poster).into(binding.ivPoster)
}
}
}
}
private fun observePaymentState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
paymentViewModel.state.collect { state ->
FileLogger.log("DetailActivity", "collect state=${state::class.simpleName}")
when (state) {
is PaymentState.Idle -> {}
is PaymentState.CheckingPermission -> {
// 静默鉴权:不弹窗、不提示,直接走后续播放
}
is PaymentState.HasPermission -> {
if (isFetchingVideoUrl) return@collect
isFetchingVideoUrl = true
dismissPaymentDialog()
playWithThirdCardNo(state.thirdCardNo)
}
is PaymentState.CreatingOrder -> {
showPaymentDialog("正在创建天卡订单...")
}
is PaymentState.WaitingForPayment -> {
// 展示二维码
showQrCode(
codeUrl = state.codeUrl,
amount = state.amount,
orderNo = state.orderNo,
expireAt = state.expireAt
)
}
is PaymentState.PaymentSuccess -> {
if (isFetchingVideoUrl) return@collect
isFetchingVideoUrl = true
countDownTimer?.cancel()
dismissPaymentDialog()
playWithThirdCardNo(state.thirdCardNo)
}
is PaymentState.PaymentTimeout -> {
countDownTimer?.cancel()
showPaymentTimeout()
}
is PaymentState.Error -> {
countDownTimer?.cancel()
isFetchingVideoUrl = false
FileLogger.log("DetailActivity", "Error: msg=${state.message} dialogShowing=${paymentDialog?.isShowing}")
if (paymentDialog?.isShowing != true) showPaymentDialog("")
updatePaymentDialog(
errorText = "失败: ${state.message}",
showCloseButton = true
)
}
}
}
}
}
}
/**
* 展示微信支付二维码
*/
private fun showQrCode(codeUrl: String, amount: Int, orderNo: String, expireAt: String) {
val db = dialogBinding ?: showPaymentDialog("").let { dialogBinding } ?: return
// 隐藏加载状态
db.progressBar.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.VISIBLE
db.flQrcode.visibility = View.VISIBLE
db.ivQrcode.visibility = View.GONE
// 金额显示(分转元)
db.tvPayAmount.visibility = View.VISIBLE
db.tvPayAmount.text = "¥${"%.2f".format(amount / 100.0)}"
// 订单号
db.tvPayOrderNo.visibility = View.VISIBLE
db.tvPayOrderNo.text = "订单号: $orderNo"
// 状态文本
db.tvPayStatus.text = "请使用微信扫码支付"
// 按钮:显示取消(用户可随时取消支付)
db.llButtons.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "取消支付"
db.btnRegenerate.visibility = View.GONE
// 倒计时
db.tvCountdown.visibility = View.VISIBLE
startCountdown(expireAt)
// 异步生成二维码
lifecycleScope.launch(Dispatchers.IO) {
try {
val bitmap = QRCodeUtil.generateQRCode(codeUrl, 400)
withContext(Dispatchers.Main) {
val currentBinding = dialogBinding ?: return@withContext
currentBinding.pbQrcodeLoading.visibility = View.GONE
currentBinding.ivQrcode.setImageBitmap(bitmap)
currentBinding.ivQrcode.visibility = View.VISIBLE
}
} catch (e: Exception) {
Log.e("DetailActivity", "QR code generation failed", e)
withContext(Dispatchers.Main) {
updatePaymentDialog(
errorText = "二维码生成失败,请重试",
showCloseButton = true
)
}
}
}
}
/**
* 启动倒计时
*/
private fun startCountdown(expireAt: String) {
countDownTimer?.cancel()
val remainMillis = calculateRemainMillis(expireAt)
if (remainMillis <= 0) {
paymentViewModel.cancelPolling()
showPaymentTimeout()
return
}
countDownTimer = object : CountDownTimer(remainMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val db = dialogBinding ?: return
val minutes = millisUntilFinished / 60000
val seconds = (millisUntilFinished % 60000) / 1000
db.tvCountdown.text = String.format("%02d:%02d", minutes, seconds)
}
override fun onFinish() {
val db = dialogBinding ?: return
db.tvCountdown.text = "00:00"
paymentViewModel.cancelPolling()
showPaymentTimeout()
}
}.start()
}
/**
* 解析 expireAt 计算剩余时间(毫秒)
*/
private fun calculateRemainMillis(expireAt: String): Long {
if (expireAt.isEmpty()) {
// 默认 15 分钟
return 15 * 60 * 1000L
}
// 尝试多种格式解析
val formats = listOf(
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss"
)
for (pattern in formats) {
try {
val sdf = SimpleDateFormat(pattern, Locale.getDefault())
val expireTime = sdf.parse(expireAt)
if (expireTime != null) {
val remain = expireTime.time - System.currentTimeMillis()
return if (remain > 0) remain else 0
}
} catch (_: Exception) {
// 尝试下一种格式
}
}
// 解析失败,默认 15 分钟
Log.w("DetailActivity", "Failed to parse expireAt: $expireAt, using default 15min")
return 15 * 60 * 1000L
}
/**
* 显示支付超时
*/
private fun showPaymentTimeout() {
val db = dialogBinding ?: return
db.ivQrcode.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.progressBar.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.tvPayAmount.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.tvPayStatus.text = "支付超时,请重新生成"
db.btnRegenerate.visibility = View.VISIBLE
db.btnClose.visibility = View.VISIBLE
}
/**
* 用 thirdCardNo 初始化 SDK 并播放:
* 1. 用 thirdCardNo 初始化SDK
* 2. 调用SDK获取视频地址
* 3. 跳转播放器
*/
private fun playWithThirdCardNo(thirdCardNo: String) {
val item = contentItem
if (item == null) {
isFetchingVideoUrl = false
Toast.makeText(this, "内容信息未加载", Toast.LENGTH_SHORT).show()
return
}
if (thirdCardNo.isEmpty()) {
isFetchingVideoUrl = false
Toast.makeText(this, "天卡卡号缺失,请重试", Toast.LENGTH_LONG).show()
return
}
JetsenxSdkUtil.init(this, thirdCardNo, object : JetsenxSdkUtil.InitCallback {
override fun onSuccess(memberCode: String) {
Log.d("DetailActivity", "SDK init success with thirdCardNo: memberCode=$memberCode")
sdkInitialized = true
fetchVideoUrlFromSdk(item)
}
override fun onFail(message: String) {
Log.e("DetailActivity", "SDK init fail with thirdCardNo: $message")
runOnUiThread {
isFetchingVideoUrl = false
SignManager.clearBenefits()
Toast.makeText(this@DetailActivity, "播放SDK初始化失败: $message", Toast.LENGTH_LONG).show()
}
}
})
}
/** 调用SDK获取视频地址并跳转播放器 */
private fun fetchVideoUrlFromSdk(item: ContentItem) {
val parentContentType = item.getSdkParentContentType()
val programCode = item.getVideoCode()
Log.d("DetailActivity", "fetchVideoUrlFromSdk: parentContentType=$parentContentType programCode=$programCode")
if (programCode.isEmpty()) {
runOnUiThread {
isFetchingVideoUrl = false
Toast.makeText(this@DetailActivity, "无法获取节目编码", Toast.LENGTH_LONG).show()
}
return
}
JetsenxSdkUtil.getAllVideoUrl(parentContentType, programCode,
object : JetsenxSdkUtil.VideoUrlCallback {
override fun onSuccess(videoUrl1080: String, videoUrl720: String, isFree: Boolean) {
runOnUiThread {
val videoUrl = if (videoUrl1080.isNotEmpty()) videoUrl1080 else videoUrl720
Log.d("DetailActivity", "获取视频地址成功: $videoUrl (isFree=$isFree)")
if (videoUrl.isEmpty()) {
isFetchingVideoUrl = false
Toast.makeText(this@DetailActivity, "未获取到视频地址", Toast.LENGTH_LONG).show()
return@runOnUiThread
}
dismissPaymentDialog()
// 跳转播放器
val intent = Intent(this@DetailActivity, VideoPlayerActivity::class.java).apply {
putExtra(VideoPlayerActivity.EXTRA_VIDEO_URL, videoUrl)
putExtra(VideoPlayerActivity.EXTRA_TITLE, item.name)
}
startActivity(intent)
}
}
override fun onError(code: String, message: String) {
runOnUiThread {
isFetchingVideoUrl = false
Log.e("DetailActivity", "获取视频地址失败: code=$code msg=$message")
Toast.makeText(this@DetailActivity, "获取视频地址失败: $message", Toast.LENGTH_LONG).show()
}
}
})
}
/** 显示支付对话框 */
private fun showPaymentDialog(statusText: String) {
if (paymentDialog?.isShowing == true) {
updatePaymentDialog(loadingText = statusText)
return
}
dialogBinding = DialogPaymentBinding.inflate(LayoutInflater.from(this))
dialogBinding?.tvPayStatus?.text = statusText
// 重新生成按钮 → 重新发起支付流程
dialogBinding?.btnRegenerate?.setOnClickListener {
countDownTimer?.cancel()
dialogBinding?.btnRegenerate?.visibility = View.GONE
dialogBinding?.btnClose?.visibility = View.GONE
isFetchingVideoUrl = false
startPaymentFlow()
}
// 关闭按钮
dialogBinding?.btnClose?.setOnClickListener {
dismissPaymentDialog()
paymentViewModel.reset()
}
// 保留兼容:确认/取消按钮(默认隐藏)
dialogBinding?.btnCancel?.setOnClickListener {
dismissPaymentDialog()
paymentViewModel.reset()
}
dialogBinding?.btnConfirm?.visibility = View.GONE
dialogBinding?.flQrcode?.visibility = View.GONE
paymentDialog = AlertDialog.Builder(this)
.setView(dialogBinding?.root)
.setCancelable(false)
.create()
paymentDialog?.window?.setBackgroundDrawableResource(android.R.color.transparent)
paymentDialog?.show()
}
/**
* 更新支付对话框状态
* @param loadingText 加载中文本(显示进度条)
* @param errorText 错误文本(显示关闭按钮)
* @param showCloseButton 是否显示关闭按钮
*/
private fun updatePaymentDialog(
loadingText: String? = null,
errorText: String? = null,
showCloseButton: Boolean = false
) {
val db = dialogBinding ?: return
val text = loadingText ?: errorText ?: ""
db.tvPayStatus.text = text
if (loadingText != null) {
// 加载状态:显示进度条 + 取消按钮(始终允许用户取消)
db.progressBar.visibility = View.VISIBLE
db.flQrcode.visibility = View.GONE
db.ivQrcode.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.tvPayAmount.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.tvPayOrderNo.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.btnRegenerate.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "取消"
} else if (errorText != null || showCloseButton) {
// 错误/完成状态:隐藏进度条,显示关闭按钮
db.progressBar.visibility = View.GONE
db.pbQrcodeLoading.visibility = View.GONE
db.flQrcode.visibility = View.GONE
db.ivQrcode.visibility = View.GONE
db.tvCountdown.visibility = View.GONE
db.llButtons.visibility = View.GONE
db.btnRegenerate.visibility = View.GONE
db.btnClose.visibility = View.VISIBLE
db.btnClose.text = "关闭"
db.tvPayAmount.visibility = View.GONE
db.btnRegenerate.visibility = View.VISIBLE
db.btnRegenerate.text = "重试"
// 订单号
val orderNo = paymentViewModel.orderNo.value
if (orderNo.isNotEmpty()) {
db.tvPayOrderNo.visibility = View.VISIBLE
db.tvPayOrderNo.text = "订单号: $orderNo"
}
}
}
private fun dismissPaymentDialog() {
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.cancelPolling()
paymentDialog?.dismiss()
paymentDialog = null
dialogBinding = null
}
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
countDownTimer = null
paymentViewModel.cancelPolling()
dismissPaymentDialog()
}
companion object {
const val EXTRA_CONTENTCODE = "extra_contentcode"
const val EXTRA_NAME = "extra_name"
}
}

View File

@@ -0,0 +1,139 @@
package com.pukka.mediaapp.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.pukka.mediaapp.databinding.ActivityHomeBinding
import com.pukka.mediaapp.network.SignManager
import com.pukka.mediaapp.ui.adapter.NavAdapter
import com.pukka.mediaapp.ui.adapter.SectionAdapter
import com.pukka.mediaapp.viewmodel.HomeViewModel
import com.pukka.mediaapp.widget.TopView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 首页:顶部导航栏 + 楼层内容区 + 搜索入口。
*
* 启动流程:
* 1. 检查设备是否已激活(有持久化的 activationCode)
* 2. 未激活 -> 弹激活码输入框 -> 调 SignManager.activate 完成 init
* 3. 已激活但无 sign -> 后台自动 doInit 获取 sign
* 4. 加载首页导航
*/
class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
private val viewModel: HomeViewModel by viewModels()
private lateinit var navAdapter: NavAdapter
private lateinit var sectionAdapter: SectionAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
setupViews()
observeViewModel()
// 启动激活/鉴权流程
when {
!SignManager.isActivated() -> showActivationDialog()
SignManager.hasSign() -> viewModel.loadNav()
else -> {
// 已激活但 sign 丢失,尝试重新 init;失败则重新弹激活框
lifecycleScope.launch {
val ok = withContext(Dispatchers.IO) { runCatching { SignManager.doInit() }.isSuccess }
if (ok) viewModel.loadNav()
else showActivationDialog()
}
}
}
}
/** 跳转激活码输入页 */
private fun showActivationDialog() {
startActivity(Intent(this, ActivationActivity::class.java))
finish()
}
/** 执行激活:已移至 ActivationActivity,此处保留兼容 */
private fun doActivate(code: String) {
startActivity(Intent(this, ActivationActivity::class.java))
finish()
}
private fun setupViews() {
navAdapter = NavAdapter { navItem ->
viewModel.loadLayout(navItem.navid)
}
binding.rvNav.apply {
layoutManager = LinearLayoutManager(this@HomeActivity, LinearLayoutManager.HORIZONTAL, false)
adapter = navAdapter
setHasFixedSize(true)
}
sectionAdapter = SectionAdapter { content -> openDetail(content.contentcode, content.name) }
binding.rvContent.apply {
layoutManager = LinearLayoutManager(this@HomeActivity, LinearLayoutManager.VERTICAL, false)
adapter = sectionAdapter
setHasFixedSize(true)
}
binding.topView.setOnTopButtonClickListener(object : TopView.OnTopButtonClickListener {
override fun onSearchClick() {
startActivity(Intent(this@HomeActivity, SearchActivity::class.java))
}
override fun onHistoryClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
override fun onOrderClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
override fun onHelpClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
override fun onServiceClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
override fun onSettingClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
override fun onCollectClick() {
Toast.makeText(this@HomeActivity, "功能开发中", Toast.LENGTH_SHORT).show()
}
})
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.navItems.collect { navAdapter.submitList(it, viewModel.selectedNavIndex.value) } }
launch { viewModel.sections.collect { sectionAdapter.submitList(it) } }
}
}
}
private fun openDetail(contentcode: String, name: String) {
startActivity(
Intent(this, DetailActivity::class.java).apply {
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
putExtra(DetailActivity.EXTRA_NAME, name)
}
)
}
}

View File

@@ -0,0 +1,97 @@
package com.pukka.mediaapp.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.databinding.ActivityListBinding
import com.pukka.mediaapp.ui.adapter.ContentAdapter
import com.pukka.mediaapp.ui.adapter.TagAdapter
import com.pukka.mediaapp.viewmodel.ListViewModel
import kotlinx.coroutines.launch
/**
* 列表页:左侧分类标签 + 右侧内容网格(2 列)+ 顶部标题。
*
* - 接收 catalogid 与 title 参数。
* - 支持分页加载:滚动接近底部时自动加载下一页。
* - 点击标签重新从第一页加载(触发筛选刷新)。
* - 点击内容项跳转 [DetailActivity],传递 contentcode 与 name。
*/
class ListActivity : AppCompatActivity() {
private lateinit var binding: ActivityListBinding
private val viewModel: ListViewModel by viewModels()
private lateinit var tagAdapter: TagAdapter
private lateinit var contentAdapter: ContentAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityListBinding.inflate(layoutInflater)
setContentView(binding.root)
val catalogid = intent.getStringExtra(EXTRA_CATALOGID).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
binding.tvTitle.text = title
setupViews()
observeViewModel()
viewModel.init(catalogid)
}
private fun setupViews() {
tagAdapter = TagAdapter { _ -> viewModel.loadContent(reset = true) }
binding.rvTags.apply {
layoutManager = LinearLayoutManager(this@ListActivity, LinearLayoutManager.VERTICAL, false)
adapter = tagAdapter
}
contentAdapter = ContentAdapter { content -> openDetail(content.contentcode, content.name) }
binding.rvContent.apply {
layoutManager = GridLayoutManager(this@ListActivity, 2)
adapter = contentAdapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
val lm = rv.layoutManager as? GridLayoutManager ?: return
val total = lm.itemCount
val lastVisible = lm.findLastVisibleItemPosition()
// 接近底部时预加载下一页
if (total > 0 && lastVisible >= total - 4) {
viewModel.loadContent(reset = false)
}
}
})
}
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.tags.collect { tagAdapter.submitList(it) } }
launch { viewModel.contentList.collect { contentAdapter.submitList(it) } }
}
}
}
private fun openDetail(contentcode: String, name: String) {
startActivity(
Intent(this, DetailActivity::class.java).apply {
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
putExtra(DetailActivity.EXTRA_NAME, name)
}
)
}
companion object {
const val EXTRA_CATALOGID = "extra_catalogid"
const val EXTRA_TITLE = "extra_title"
}
}

View File

@@ -0,0 +1,75 @@
package com.pukka.mediaapp.ui
import android.content.Intent
import android.os.Bundle
import android.view.inputmethod.EditorInfo
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.pukka.mediaapp.databinding.ActivitySearchBinding
import com.pukka.mediaapp.ui.adapter.SearchAdapter
import com.pukka.mediaapp.viewmodel.SearchViewModel
import kotlinx.coroutines.launch
/**
* 搜索页:搜索输入框 + 搜索按钮 + 结果列表。
*
* - 点击搜索按钮或键盘搜索动作触发搜索。
* - 点击结果项跳转 [DetailActivity],传递 contentcode 与 name。
*/
class SearchActivity : AppCompatActivity() {
private lateinit var binding: ActivitySearchBinding
private val viewModel: SearchViewModel by viewModels()
private lateinit var searchAdapter: SearchAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySearchBinding.inflate(layoutInflater)
setContentView(binding.root)
searchAdapter = SearchAdapter { content -> openDetail(content.contentcode, content.name) }
binding.rvResults.apply {
layoutManager = LinearLayoutManager(this@SearchActivity)
adapter = searchAdapter
}
binding.btnSearch.setOnClickListener { doSearch() }
binding.etSearch.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
doSearch()
true
} else {
false
}
}
observeViewModel()
}
private fun observeViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.results.collect { searchAdapter.submitList(it) }
}
}
}
private fun doSearch() {
val keyword = binding.etSearch.text?.toString().orEmpty()
viewModel.search(keyword)
}
private fun openDetail(contentcode: String, name: String) {
startActivity(
Intent(this, DetailActivity::class.java).apply {
putExtra(DetailActivity.EXTRA_CONTENTCODE, contentcode)
putExtra(DetailActivity.EXTRA_NAME, name)
}
)
}
}

View File

@@ -0,0 +1,169 @@
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播放视频
*
* 接收Intent: EXTRA_VIDEO_URL (视频地址), EXTRA_TITLE (标题)
*/
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, "MediaApp")
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 -> {
Log.d(TAG, "STATE_READY")
showLoading(false)
}
Player.STATE_BUFFERING -> {
Log.d(TAG, "STATE_BUFFERING")
showLoading(true)
}
Player.STATE_ENDED -> {
Log.d(TAG, "STATE_ENDED")
showLoading(false)
}
Player.STATE_IDLE -> {
Log.d(TAG, "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,98 @@
package com.pukka.mediaapp.ui.adapter
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.View
/**
* 适配器公共工具:统一管理主题色、焦点高亮与选中下划线。
*
* 设计风格:
* - 深色背景 #000910
* - 强调色 #FF7417(橙色)
* - 文字白色透明度分级(100% / 80% / 60% / 40%)
* - 金色 #DDB881 用于 VIP 标签
* - 卡片圆角约 8dp,焦点态使用白色边框高亮
*/
object AppColors {
val BACKGROUND = Color.parseColor("#000910")
val ACCENT = Color.parseColor("#FF7417")
val GOLD = Color.parseColor("#DDB881")
/** 主标题:100% 白色 */
val TEXT_PRIMARY = Color.WHITE
/** 副标题:80% 白色 */
val TEXT_SECONDARY = Color.parseColor("#CCFFFFFF")
/** 次要文字:60% 白色 */
val TEXT_TERTIARY = Color.parseColor("#99FFFFFF")
/** 弱化文字:40% 白色 */
val TEXT_WEAK = Color.parseColor("#66FFFFFF")
}
/** dp 转 px */
internal fun dp(context: Context, value: Float): Int =
(value * context.resources.displayMetrics.density + 0.5f).toInt()
/**
* 为列表项设置焦点高亮:获取焦点时显示白色圆角描边并轻微放大,
* 失去焦点时还原。适配 TV/遥控器焦点导航。
*/
fun applyFocusBorder(
view: View,
cornerRadiusDp: Float = 10f,
strokeDp: Float = 2f,
scale: Float = 1.05f
) {
view.isFocusable = true
view.setOnFocusChangeListener { v, hasFocus ->
v.foreground = if (hasFocus) {
GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(v.context, cornerRadiusDp).toFloat()
setStroke(dp(v.context, strokeDp), AppColors.ACCENT)
setColor(Color.TRANSPARENT)
}
} else {
null
}
v.scaleX = if (hasFocus) scale else 1f
v.scaleY = if (hasFocus) scale else 1f
if (hasFocus) v.bringToFront() else v.translationZ = 0f
}
}
/**
* 仅在底部绘制一条彩色线的 Drawable,用于导航选中项的橙色下划线。
*/
class BottomLineDrawable(
color: Int,
private val lineHeightPx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color }
override fun draw(canvas: Canvas) {
val b = bounds
if (b.height() <= 0 || b.width() <= 0) return
val top = (b.bottom - lineHeightPx).toFloat()
canvas.drawRect(b.left.toFloat(), top, b.right.toFloat(), b.bottom.toFloat(), paint)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}

View File

@@ -0,0 +1,85 @@
package com.pukka.mediaapp.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.pukka.mediaapp.databinding.ItemContentBinding
import com.pukka.mediaapp.model.ContentItem
/**
* 内容卡片适配器(16:9 横图卡片)。
*
* - 使用 Glide 加载海报(getPosterUrl())。
* - 标题叠加在图片底部渐变遮罩上。
* - 卡片宽度由 [cardWidthDp] 控制,适配不同楼层类型。
* - 焦点态橙色边框高亮 + 轻微放大。
*
* @param cardWidthDp 卡片宽度(dp),高度自动按 16:9 计算
*/
class ContentAdapter(
private var cardWidthDp: Int = 200,
private val onClick: (ContentItem) -> Unit
) : RecyclerView.Adapter<ContentAdapter.ViewHolder>() {
private val items = mutableListOf<ContentItem>()
inner class ViewHolder(val binding: ItemContentBinding) : RecyclerView.ViewHolder(binding.root) {
init {
applyFocusBorder(itemView)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContentBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
/** 更新卡片宽度(楼层类型变化时调用) */
fun updateCardWidth(widthDp: Int) {
if (widthDp == cardWidthDp) return
cardWidthDp = widthDp
notifyDataSetChanged()
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val widthPx = dp(holder.itemView.context, cardWidthDp.toFloat())
val heightPx = widthPx * 9 / 16
with(holder.binding) {
ivPoster.layoutParams.width = widthPx
ivPoster.layoutParams.height = heightPx
vGradient.layoutParams.width = widthPx
vGradient.layoutParams.height = heightPx / 3 + dp(root.context, 4f)
tvName.layoutParams.width = widthPx
Glide.with(holder.itemView)
.load(item.getPosterUrl())
.into(ivPoster)
tvName.text = item.name
tvName.setTextColor(AppColors.TEXT_PRIMARY)
holder.itemView.setOnClickListener { onClick(item) }
}
}
override fun getItemCount(): Int = items.size
fun submitList(list: List<ContentItem>) {
items.clear()
items.addAll(list)
notifyDataSetChanged()
}
fun addItems(list: List<ContentItem>) {
if (list.isEmpty()) return
val start = items.size
items.addAll(list)
notifyItemRangeInserted(start, list.size)
}
fun clear() {
items.clear()
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,69 @@
package com.pukka.mediaapp.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.databinding.ItemNavBinding
import androidx.core.content.ContextCompat
import com.pukka.mediaapp.R
import com.pukka.mediaapp.model.NavItem
/**
* 首页左侧导航栏适配器。
*
* 选中态:文字白色加粗 + 底部橙色下划线;
* 未选中态:文字灰色(60% 白)。
*/
class NavAdapter(
private val onClick: (NavItem) -> Unit
) : RecyclerView.Adapter<NavAdapter.ViewHolder>() {
private val items = mutableListOf<NavItem>()
private var selectedPos = RecyclerView.NO_POSITION
inner class ViewHolder(val binding: ItemNavBinding) : RecyclerView.ViewHolder(binding.root) {
init {
itemView.isFocusable = true
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemNavBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val selected = position == selectedPos
with(holder.binding.tvNavName) {
text = item.navname
paint.isFakeBoldText = selected
if (selected) {
setTextColor(AppColors.ACCENT)
background = ContextCompat.getDrawable(context, R.drawable.bg_nav_selected)
} else {
setTextColor(AppColors.TEXT_TERTIARY)
background = null
}
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION || pos == selectedPos) return@setOnClickListener
val old = selectedPos
selectedPos = pos
if (old != RecyclerView.NO_POSITION) notifyItemChanged(old)
notifyItemChanged(selectedPos)
items.getOrNull(selectedPos)?.let(onClick)
}
}
override fun getItemCount(): Int = items.size
/** 提交新数据,可指定默认选中位置。 */
fun submitList(list: List<NavItem>, selectedIndex: Int = 0) {
items.clear()
items.addAll(list)
selectedPos = if (list.isNotEmpty()) selectedIndex.coerceIn(0, list.size - 1) else RecyclerView.NO_POSITION
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,60 @@
package com.pukka.mediaapp.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.pukka.mediaapp.databinding.ItemSearchResultBinding
import com.pukka.mediaapp.model.ContentItem
/**
* 搜索结果适配器。
*
* 展示海报、名称、类型/地区、演员,使用 Glide 加载图片,焦点态白色边框高亮。
*/
class SearchAdapter(
private val onClick: (ContentItem) -> Unit
) : RecyclerView.Adapter<SearchAdapter.ViewHolder>() {
private val items = mutableListOf<ContentItem>()
inner class ViewHolder(val binding: ItemSearchResultBinding) : RecyclerView.ViewHolder(binding.root) {
init {
applyFocusBorder(itemView)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding =
ItemSearchResultBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
with(holder.binding) {
Glide.with(holder.itemView)
.load(item.getPosterUrl())
.into(ivPoster)
tvName.text = item.name
tvName.setTextColor(AppColors.TEXT_PRIMARY)
tvType.text = item.getTypeText()
tvType.setTextColor(AppColors.TEXT_TERTIARY)
tvActor.text = item.getActorText()
tvActor.setTextColor(AppColors.TEXT_SECONDARY)
holder.itemView.setOnClickListener { onClick(item) }
}
}
override fun getItemCount(): Int = items.size
fun submitList(list: List<ContentItem>) {
items.clear()
items.addAll(list)
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,77 @@
package com.pukka.mediaapp.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.databinding.ItemSectionBinding
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.model.LayoutSection
/**
* 首页楼层适配器:每个 item 包含一个楼层标题 + 水平内容列表。
*
* 根据楼层 type 分配不同卡片宽度:
* - imgHFloorR1C2: 大 banner(320dp)
* - imgSFloorR1C6s / imgHFloorR1C6: 小卡(150dp)
* - 其他: 标准卡(200dp)
*/
class SectionAdapter(
private val onContentClick: (ContentItem) -> Unit
) : RecyclerView.Adapter<SectionAdapter.ViewHolder>() {
private val sections = mutableListOf<LayoutSection>()
inner class ViewHolder(val binding: ItemSectionBinding) : RecyclerView.ViewHolder(binding.root) {
val contentAdapter = ContentAdapter(onClick = onContentClick)
init {
binding.rvSectionContent.apply {
layoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
adapter = contentAdapter
setHasFixedSize(true)
isNestedScrollingEnabled = false
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemSectionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val section = sections[position]
// 过滤布局描述类标题("一行-一拖四"、"二行-一排四个" 等)
val title = section.title
val isLayoutDesc = title.contains("拖") || title.contains("排") || title.contains("并列")
if (isLayoutDesc || title.isEmpty()) {
holder.binding.tvSectionTitle.visibility = android.view.View.GONE
// 同时隐藏标题行(橙色竖条 + 标题的容器)
(holder.binding.tvSectionTitle.parent as? android.view.View)?.visibility = android.view.View.GONE
} else {
holder.binding.tvSectionTitle.visibility = android.view.View.VISIBLE
(holder.binding.tvSectionTitle.parent as? android.view.View)?.visibility = android.view.View.VISIBLE
holder.binding.tvSectionTitle.text = title
holder.binding.tvSectionTitle.setTextColor(AppColors.TEXT_PRIMARY)
}
// 按楼层类型设置卡片宽度
val cardWidth = when {
section.type.contains("R1C2") -> 320 // 大 banner
section.type.contains("R1C6") -> 150 // 小卡(6 个一行)
else -> 200 // 标准卡
}
holder.contentAdapter.updateCardWidth(cardWidth)
holder.contentAdapter.submitList(section.extraData ?: emptyList())
}
override fun getItemCount(): Int = sections.size
fun submitList(list: List<LayoutSection>) {
sections.clear()
sections.addAll(list)
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,66 @@
package com.pukka.mediaapp.ui.adapter
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.databinding.ItemTagBinding
import com.pukka.mediaapp.model.TagItem
/**
* 分类标签适配器。
*
* 选中态:橙色圆角背景 + 白色加粗文字;
* 未选中态:半透明白色圆角背景 + 80% 白色文字。
*/
class TagAdapter(
private val onClick: (TagItem) -> Unit
) : RecyclerView.Adapter<TagAdapter.ViewHolder>() {
private val items = mutableListOf<TagItem>()
private var selectedPos = 0
inner class ViewHolder(val binding: ItemTagBinding) : RecyclerView.ViewHolder(binding.root) {
init {
itemView.isFocusable = true
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemTagBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
val selected = position == selectedPos
with(holder.binding.tvTagName) {
text = item.name
paint.isFakeBoldText = selected
setTextColor(if (selected) AppColors.TEXT_PRIMARY else AppColors.TEXT_SECONDARY)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(context, 16f).toFloat()
setColor(if (selected) AppColors.ACCENT else 0x22FFFFFF)
}
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION || pos == selectedPos) return@setOnClickListener
val old = selectedPos
selectedPos = pos
if (old != RecyclerView.NO_POSITION) notifyItemChanged(old)
notifyItemChanged(selectedPos)
items.getOrNull(selectedPos)?.let(onClick)
}
}
override fun getItemCount(): Int = items.size
fun submitList(list: List<TagItem>) {
items.clear()
items.addAll(list)
selectedPos = if (list.isNotEmpty()) 0 else RecyclerView.NO_POSITION
notifyDataSetChanged()
}
}

View File

@@ -0,0 +1,237 @@
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 盒子:
*
* | 标识 | 来源 | 权限 | 重装不变 | 说明 |
* |-----------|-------------------------------|---------|---------|--------------------------|
* | MAC | NetworkInterface / sys 节点 | 无 | 是 | eth0(有线) / wlan0(WiFi) |
* | deviceSn | getprop ro.serialno | 无 | 是 | TV 盒子系统属性 |
* | androidId | Settings.Secure.ANDROID_ID | 无 | 是 | Android 8+ 按签名键隔离 |
* | deviceUid | UUID 存 SharedPreferences | 无 | 否 | 兜底,始终有值 |
*
* init 请求同时传递所有字段,后端按优先级降权匹配设备。
*/
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?, // 有线 MAC(eth0 优先)
val wifiMac: String?, // WiFi MAC(wlan0)
val deviceSn: String?, // 设备序列号(getprop ro.serialno 优先)
val deviceUid: String, // Android ID 优先,UUID 兜底(始终有值)
val androidId: String?, // Settings.Secure.ANDROID_ID(独立字段,便于后端区分)
val manufacturer: String?, // 厂商(Build.MANUFACTURER)
val model: String?, // 型号(Build.MODEL)
val brand: String? // 品牌(Build.BRAND)
)
/**
* 获取设备标识(所有字段同时返回,后端按优先级降权匹配)
*/
fun getDeviceIdentity(context: Context): DeviceIdentity {
val ethMac = getMacFromInterface("eth0") ?: getMacFromSysFile("eth0") ?: getMacFromInterface("eth")
val wifiMac = getMacFromInterface("wlan0") ?: getMacFromSysFile("wlan0") ?: getMacFromInterface("wlan")
// MAC 兜底1:遍历所有非 lo 接口
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)
// MAC 兜底2:真实 MAC 拿不到时,用 androidId 派生稳定伪 MAC(02: 前缀标识非硬件)
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
}
// ===== MAC 地址 =====
/** 从 NetworkInterface 获取指定接口的 MAC */
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
}
}
/** 从 /sys/class/net/{iface}/address 读取 MAC */
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
}
}
/** 遍历所有非 lo 接口,返回第一个有效 MAC */
private fun getFirstAvailableMac(): String? {
return try {
// 先试 NetworkInterface
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
}
}
// 再试 sys 文件
for (iface in listOf("eth0", "wlan0", "eth1", "wlan1")) {
getMacFromSysFile(iface)?.let { return it }
}
null
} catch (e: Exception) {
null
}
}
// ===== 设备序列号 =====
/**
* 获取设备 SN
* 策略:getprop ro.serialno(TV 盒子可用)-> Build.getSerial()(需特权,大概率失败)
*/
private fun getDeviceSn(): String? {
// 方式1: getprop ro.serialno(大多数 TV 盒子可用,无需特权)
val propSn = getSystemProperty("ro.serialno")
if (!propSn.isNullOrEmpty() && propSn != "unknown") return propSn
// 方式2: getprop ro.boot.serialno
val bootSn = getSystemProperty("ro.boot.serialno")
if (!bootSn.isNullOrEmpty() && bootSn != "unknown") return bootSn
// 方式3: Build.getSerial()(Android 8+ 需 READ_PRIVILEGED_PHONE_STATE,大概率失败)
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) {
Log.d(TAG, "getDeviceSn Build.getSerial failed: ${e.message}")
null
}
}
/** 通过 getprop 读取系统属性 */
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
}
}
// ===== 伪 MAC 派生 =====
/**
* 用 androidId 派生一个稳定的伪 MAC 地址
* - 02: 前缀表示本地管理地址(非硬件 MAC),便于后端区分
* - 取 androidId 前 10 位 hex(5 字节)+ 02 前缀 = 6 字节 MAC
* - 同一设备重装不变(androidId 稳定),不同设备不同
*/
private fun generatePseudoMac(androidId: String): String {
val hex = androidId.take(10).padEnd(10, '0')
return ("02" + hex).chunked(2).joinToString(":") { it.uppercase() }
}
// ===== Android ID =====
/**
* 获取 Android ID(Settings.Secure.ANDROID_ID)
* - 无需权限
* - 工厂重置才变,App 重装不变
* - Android 8+ 按签名键隔离,同一 App 在同一设备上值稳定
*/
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) {
Log.e(TAG, "getAndroidId failed", e)
null
}
}
// ===== 设备 UID(兜底) =====
/**
* 获取设备 UID(始终有值)
* 优先使用 Android ID,不可用时生成 UUID 存 SharedPreferences
*/
private fun getDeviceUid(context: Context, androidId: String?): String {
// Android ID 可用直接用
if (!androidId.isNullOrEmpty()) return androidId
// 兜底:UUID 存 SharedPreferences
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()
Log.d(TAG, "Generated new device UID (UUID fallback): $uid")
}
return uid
}
}

View File

@@ -0,0 +1,105 @@
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
/**
* 文件日志工具
*
* 将关键错误/诊断信息写入应用专属外部目录下的日志文件,
* 便于 adb pull 或文件管理器导出分析(无需存储权限)。
*
* 日志路径:Android/data/com.pukka.mediaapp/files/logs/daycard.log
*/
object FileLogger {
private const val TAG = "FileLogger"
private const val LOG_DIR = "logs"
private const val LOG_FILE = "daycard.log"
private const val MAX_FILE_SIZE = 2 * 1024 * 1024L // 2MB,超出则重置
private lateinit var appContext: Context
private val lock = Any()
/** Application 启动时调用,初始化上下文 */
fun init(context: Context) {
appContext = context.applicationContext
log("FileLogger", "FileLogger initialized, log dir=${getLogDir()?.absolutePath}")
}
/** 写入一条 INFO 级别日志 */
fun log(tag: String, message: String) {
write("I", tag, message, null)
}
/** 写入一条 ERROR 级别日志(含异常堆栈) */
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())
// 同时输出到 logcat
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 {
// 优先使用外部专属目录(adb 可直接 pull,无需权限)
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,40 @@
package com.pukka.mediaapp.utils
import android.graphics.Bitmap
import android.graphics.Color
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
/**
* 二维码生成工具
*
* 用于将微信支付 codeUrl 生成二维码 Bitmap 展示在弹窗中。
*/
object QRCodeUtil {
/**
* 根据内容生成二维码 Bitmap
* @param content 二维码内容(如 weixin://wxpay/bizpayurl?pr=xxxxx)
* @param size 图片尺寸(像素),默认 400
* @return 二维码 Bitmap
*/
fun generateQRCode(content: String, size: Int = 400): Bitmap {
val hints = mapOf(
EncodeHintType.CHARACTER_SET to "UTF-8",
EncodeHintType.MARGIN to 1,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val width = bitMatrix.width
val height = bitMatrix.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
}
}
return bitmap
}
}

View File

@@ -0,0 +1,80 @@
package com.pukka.mediaapp.utils;
import android.content.Context;
import android.view.View;
import android.widget.TextView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.view.ViewCompat;
import com.pukka.mediaapp.R;
/**
* Author: wxf
* Date: 2023/1/12 10:48
* Description:Top样式切换
*/
public class TopStyleUtils {
// 搜索、历史、订购、帮助 样式切换
public static void topStyleChange(Context context, View view, boolean focus) {
// ConstraintLayout constraintLayout = (ConstraintLayout) view;
// View icon = constraintLayout.getChildAt(0);
// icon.setSelected(focus);
// TextView textView = (TextView) constraintLayout.getChildAt(1);
if (focus) {
view.animate().scaleX(1.1f).scaleY(1.1f).setDuration(100).start();
// textView.setTextColor(context.getResources().getColor(R.color.index_top_text_focus));
// textView.getPaint().setFakeBoldText(true);
} else {
view.animate().scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
// textView.setTextColor(context.getResources().getColor(R.color.index_top_text_nor));
// textView.getPaint().setFakeBoldText(false);
}
}
public static void topImageChange(View view, boolean focus) {
if (focus) {
ViewCompat.animate(view).scaleX(1.05f).scaleY(1.05f).setDuration(100).start();
} else {
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}
}
public static void topImageChangeFilter(View view, boolean focus) {
if (focus) {
ViewCompat.animate(view).scaleX(1.02f).scaleY(1.02f).setDuration(100).start();
} else {
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}
}
/**
* 一行的大模板 放大 防止焦点框外溢
* */
public static void topImageChange3(View view, boolean focus) {
if (focus) {
ViewCompat.animate(view).scaleX(1.02f).scaleY(1.05f).setDuration(100).start();
} else {
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}
}
/**
* 对view放大1.05f
*
* @param view view
* @param focus focus
*/
public static void topImageChange2(View view, boolean focus) {
if (focus) {
ViewCompat.animate(view).scaleX(1.03f).scaleY(1.05f).setDuration(100).start();
} else {
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}
}
public static void topImageChange4(View view, boolean focus) {
if (focus) {
ViewCompat.animate(view).scaleX(1.03f).scaleY(1.03f).setDuration(100).start();
} else {
ViewCompat.animate(view).scaleX(1.0f).scaleY(1.0f).setDuration(100).start();
}
}
}

View File

@@ -0,0 +1,46 @@
package com.pukka.mediaapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.network.MediaRepository
import com.pukka.mediaapp.utils.FileLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 详情页 ViewModel:根据 contentcode 加载媒资详情。
*/
class DetailViewModel : ViewModel() {
private val repository = MediaRepository.getInstance()
private val _detail = MutableStateFlow<ContentItem?>(null)
val detail: StateFlow<ContentItem?> = _detail.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
/** 加载内容详情。 */
fun loadDetail(contentcode: String) {
if (contentcode.isEmpty()) return
viewModelScope.launch {
_loading.value = true
repository.getContentDetail(contentcode)
.onSuccess { response ->
val item = response.result?.firstOrNull()
_detail.value = item
FileLogger.log("DetailVM", "详情加载: contentcode=$contentcode name=${item?.name} hasData=${item != null}")
}
.onFailure {
_error.value = it.message ?: "加载详情失败"
}
_loading.value = false
}
}
}

View File

@@ -0,0 +1,98 @@
package com.pukka.mediaapp.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pukka.mediaapp.model.LayoutSection
import com.pukka.mediaapp.model.NavItem
import com.pukka.mediaapp.network.MediaRepository
import com.pukka.mediaapp.utils.FileLogger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 首页 ViewModel:负责导航栏数据与二级布局楼层数据。
*/
class HomeViewModel : ViewModel() {
private val repository = MediaRepository.getInstance()
private val _navItems = MutableStateFlow<List<NavItem>>(emptyList())
val navItems: StateFlow<List<NavItem>> = _navItems.asStateFlow()
private val _sections = MutableStateFlow<List<LayoutSection>>(emptyList())
val sections: StateFlow<List<LayoutSection>> = _sections.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
/** 默认选中的导航项位置 */
private val _selectedNavIndex = MutableStateFlow(0)
val selectedNavIndex: StateFlow<Int> = _selectedNavIndex.asStateFlow()
private var hasLoadedNav = false
/** 加载首页导航,加载成功后默认请求 isFocus=1 的导航项对应的布局。 */
fun loadNav() {
if (hasLoadedNav) return
viewModelScope.launch {
_loading.value = true
repository.getLayoutNav()
.onSuccess { response ->
val allItems = response.data
?.flatMap { it.navList ?: emptyList() }
?: emptyList()
// 只保留有布局数据的标签(type=1 为内容页)
val contentItems = allItems.filter { it.type == 1 }
_navItems.value = contentItems
FileLogger.log("HomeVM", "导航加载: ${contentItems.size} 个标签, 默认选中=${contentItems.indexOfFirst { it.isFocus == "1" }}")
hasLoadedNav = true
if (contentItems.isNotEmpty()) {
// 优先选 isFocus=="1" 的,否则选第一个
val defaultIndex = contentItems.indexOfFirst { it.isFocus == "1" }
val targetIndex = if (defaultIndex >= 0) defaultIndex else 0
_selectedNavIndex.value = targetIndex
loadLayout(contentItems[targetIndex].navid)
} else {
_loading.value = false
}
}
.onFailure {
_error.value = it.message ?: "加载导航失败"
_loading.value = false
}
}
}
/** 根据 navid 加载二级页面布局(楼层列表)。 */
fun loadLayout(navid: String) {
if (navid.isEmpty()) return
viewModelScope.launch {
_loading.value = true
repository.getLayout(navid)
.onSuccess { response ->
val sections = response.data ?: emptyList()
Log.d("HomeViewModel", "loadLayout success: ${sections.size} sections")
FileLogger.log("HomeVM", "布局加载: navid=$navid ${sections.size} 个楼层")
_sections.value = sections
}
.onFailure {
Log.e("HomeViewModel", "loadLayout failed", it)
_error.value = it.message ?: "加载内容失败"
_sections.value = emptyList()
}
_loading.value = false
}
}
/** 更新选中的导航索引 */
fun selectNav(index: Int) {
_selectedNavIndex.value = index
}
}

View File

@@ -0,0 +1,80 @@
package com.pukka.mediaapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.model.TagItem
import com.pukka.mediaapp.network.MediaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 列表页 ViewModel:负责左侧分类标签与右侧内容网格(含分页)。
*/
class ListViewModel : ViewModel() {
private val repository = MediaRepository.getInstance()
private val _tags = MutableStateFlow<List<TagItem>>(emptyList())
val tags: StateFlow<List<TagItem>> = _tags.asStateFlow()
private val _contentList = MutableStateFlow<List<ContentItem>>(emptyList())
val contentList: StateFlow<List<ContentItem>> = _contentList.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _hasMore = MutableStateFlow(true)
val hasMore: StateFlow<Boolean> = _hasMore.asStateFlow()
private var catalogid: String = ""
private var currentPage = 1
private var isLoading = false
/** 初始化:加载标签与第一页内容。重复调用相同 catalogid 会被忽略。 */
fun init(catalogid: String) {
if (this.catalogid == catalogid && _contentList.value.isNotEmpty()) return
this.catalogid = catalogid
loadTags(catalogid)
loadContent(reset = true)
}
/** 加载分类标签。 */
fun loadTags(parentid: String) {
viewModelScope.launch {
repository.getTagsList(parentid)
.onSuccess { response -> _tags.value = response.result ?: emptyList() }
.onFailure { _tags.value = emptyList() }
}
}
/** 加载内容列表,reset=true 表示重新从第一页加载(用于切换筛选)。 */
fun loadContent(reset: Boolean = false) {
if (catalogid.isEmpty()) return
if (isLoading) return
if (reset) {
currentPage = 1
_hasMore.value = true
} else if (!_hasMore.value) {
return
}
isLoading = true
_loading.value = true
viewModelScope.launch {
repository.getContentList(catalogid, currentPage)
.onSuccess { response ->
val list = response.result ?: emptyList()
_contentList.value = if (reset) list else _contentList.value + list
currentPage++
_hasMore.value = currentPage <= response.pagetotal
}
.onFailure {
if (!reset) _hasMore.value = false
}
_loading.value = false
isLoading = false
}
}
}

View File

@@ -0,0 +1,249 @@
package com.pukka.mediaapp.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pukka.mediaapp.config.DayCardConfig
import com.pukka.mediaapp.network.DayCardRetrofitClient
import com.pukka.mediaapp.network.SignManager
import com.pukka.mediaapp.utils.FileLogger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import retrofit2.HttpException
/**
* 支付流程状态(对齐生产方案:checkBenefits 返回 thirdCardNo 直接播放)
*/
sealed class PaymentState {
object Idle : PaymentState()
object CheckingPermission : PaymentState()
/** 有天卡权益,可用 thirdCardNo 播放 */
data class HasPermission(val thirdCardNo: String) : PaymentState()
object CreatingOrder : PaymentState()
/** 等待支付(展示二维码) */
data class WaitingForPayment(
val orderNo: String,
val amount: Int, // 金额(分)
val codeUrl: String, // 微信支付二维码链接
val expireAt: String // 二维码过期时间
) : PaymentState()
/** 支付成功,可用 thirdCardNo 播放 */
data class PaymentSuccess(val thirdCardNo: String) : PaymentState()
object PaymentTimeout : PaymentState()
data class Error(val message: String) : PaymentState()
}
/**
* 天卡支付 ViewModel(对齐生产方案)
*
* 完整流程:
* 1. ensureSign → 保证 sign 有效(必要时自动 init / refresh)
* 2. checkBenefits → ACTIVE 则用 thirdCardNo 播放
* 3. UNBOUND → createOrder → 展示微信支付二维码
* 4. 轮询 getOrderStatus → PAID → 再 checkBenefits 拿 thirdCardNo → 播放
* 5. CANCELLED/超时 → 展示重新生成
*
* 设备标识不再由 App 每次传递,init 时传一次,后续从 sign 解析。
*/
class PaymentViewModel : ViewModel() {
private val api = DayCardRetrofitClient.apiService
private val _state = MutableStateFlow<PaymentState>(PaymentState.Idle)
val state: StateFlow<PaymentState> = _state.asStateFlow()
private val _orderNo = MutableStateFlow("")
val orderNo: StateFlow<String> = _orderNo.asStateFlow()
private var pollJob: Job? = null
private var flowJob: Job? = null
/**
* 开始支付/鉴权流程。
* mediaId 不再传给 checkBenefits(checkBenefits 返回设备级权益)。
*/
fun startPaymentFlow() {
cancelAllJobs()
_state.value = PaymentState.Idle
// 权益缓存有效,直接播放(静默,不鉴权、不弹窗)
val cached = SignManager.getCachedThirdCardNo()
if (cached.isNotEmpty()) {
FileLogger.log("PaymentVM", "使用缓存权益播放: thirdCardNo=$cached")
_state.value = PaymentState.HasPermission(cached)
return
}
flowJob = viewModelScope.launch {
_state.value = PaymentState.CheckingPermission
try {
// Step1: 确保 sign 有效
SignManager.ensureSign()
// Step2: 查询天卡权益
val benefits = checkBenefitsWithRetry()
FileLogger.log("PaymentVM", "checkBenefits: code=${benefits.code} msg=${benefits.msg} status=${benefits.status} isActive=${benefits.isActive} thirdCardNo=${benefits.thirdCardNo}")
if (benefits.isActive && benefits.thirdCardNo.isNotEmpty()) {
SignManager.cacheBenefits(benefits.thirdCardNo, benefits.expireAt)
_state.value = PaymentState.HasPermission(benefits.thirdCardNo)
return@launch
}
// Step3: 无权益 → 创建订单
_state.value = PaymentState.CreatingOrder
val orderResp = createOrderWithRetry()
FileLogger.log("PaymentVM", "createOrder: code=${orderResp.code} msg=${orderResp.msg} orderNo=${orderResp.orderNo} codeUrl=${orderResp.codeUrl} amount=${orderResp.amount}")
if (!orderResp.isSuccess || orderResp.orderNo.isEmpty()) {
FileLogger.error("PaymentVM", "createOrder 业务失败: code=${orderResp.code} msg=${orderResp.msg}")
_state.value = PaymentState.Error(orderResp.msg.ifEmpty { "创建订单失败" })
return@launch
}
_orderNo.value = orderResp.orderNo
if (orderResp.codeUrl.isEmpty()) {
FileLogger.error("PaymentVM", "createOrder codeUrl 为空")
_state.value = PaymentState.Error("未获取到支付二维码,请重试")
return@launch
}
// Step4: 展示二维码,等待支付
_state.value = PaymentState.WaitingForPayment(
orderNo = orderResp.orderNo,
amount = orderResp.amount,
codeUrl = orderResp.codeUrl,
expireAt = orderResp.expireAt
)
// Step5: 轮询订单状态
startPollingOrderStatus(orderResp.orderNo)
} catch (e: CancellationException) {
// 正常取消
} catch (e: Exception) {
Log.e("PaymentVM", "startPaymentFlow failed", e)
FileLogger.error("PaymentVM", "startPaymentFlow 异常: ${e.javaClass.simpleName}: ${e.message}", e)
_state.value = PaymentState.Error(e.message ?: "网络错误")
}
}
}
/** 查询权益,401 时重新 init 后重试一次 */
private suspend fun checkBenefitsWithRetry() =
checkBenefitsWithRetry(0)
private suspend fun checkBenefitsWithRetry(retried: Int): com.pukka.mediaapp.model.daycard.CheckBenefitsResponse {
return try {
api.checkBenefits()
} catch (e: HttpException) {
if (e.code() == 401 && retried == 0) {
Log.w("PaymentVM", "checkBenefits 401, re-init and retry")
SignManager.doInit()
checkBenefitsWithRetry(1)
} else throw e
}
}
/** 创建订单,401 时重新 init 后重试一次 */
private suspend fun createOrderWithRetry() =
createOrderWithRetry(0)
private suspend fun createOrderWithRetry(retried: Int): com.pukka.mediaapp.model.daycard.CreateOrderResponse {
return try {
api.createOrder()
} catch (e: HttpException) {
if (e.code() == 401 && retried == 0) {
Log.w("PaymentVM", "createOrder 401, re-init and retry")
SignManager.doInit()
createOrderWithRetry(1)
} else throw e
}
}
/**
* 轮询订单状态,直到 PAID / CANCELLED / 超时
*/
private fun startPollingOrderStatus(orderNo: String) {
pollJob = viewModelScope.launch {
val startTime = System.currentTimeMillis()
while (isActive) {
if (System.currentTimeMillis() - startTime >= DayCardConfig.POLL_TIMEOUT) {
_state.value = PaymentState.PaymentTimeout
return@launch
}
delay(DayCardConfig.POLL_INTERVAL)
try {
val response = api.getOrderStatus(orderNo)
if (response.isSuccess) {
when (response.status) {
"PAID" -> {
// 支付成功后查询权益拿 thirdCardNo
val benefits = try { api.checkBenefits() } catch (e: Exception) {
Log.e("PaymentVM", "post-pay checkBenefits failed", e)
null
}
if (benefits != null && benefits.isActive && benefits.thirdCardNo.isNotEmpty()) {
SignManager.cacheBenefits(benefits.thirdCardNo, benefits.expireAt)
_state.value = PaymentState.PaymentSuccess(benefits.thirdCardNo)
} else {
// 权益未生效,重试一次
delay(2000)
val retry = try { api.checkBenefits() } catch (_: Exception) { null }
if (retry != null && retry.isActive && retry.thirdCardNo.isNotEmpty()) {
SignManager.cacheBenefits(retry.thirdCardNo, retry.expireAt)
_state.value = PaymentState.PaymentSuccess(retry.thirdCardNo)
} else {
_state.value = PaymentState.Error("支付成功但权益未生效,请稍后重试")
}
}
return@launch
}
"CANCELLED" -> {
_state.value = PaymentState.PaymentTimeout
return@launch
}
// CREATED / PAYING → 继续轮询
}
}
} catch (e: CancellationException) {
return@launch
} catch (e: Exception) {
Log.e("PaymentVM", "pollOrderStatus error", e)
// 网络错误不中断轮询
}
}
}
}
fun cancelPolling() {
pollJob?.cancel()
pollJob = null
}
private fun cancelAllJobs() {
pollJob?.cancel()
pollJob = null
flowJob?.cancel()
flowJob = null
}
fun reset() {
cancelAllJobs()
_state.value = PaymentState.Idle
_orderNo.value = ""
}
override fun onCleared() {
super.onCleared()
cancelAllJobs()
}
}

View File

@@ -0,0 +1,51 @@
package com.pukka.mediaapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pukka.mediaapp.model.ContentItem
import com.pukka.mediaapp.network.MediaRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 搜索页 ViewModel:根据关键词搜索媒资。
*/
class SearchViewModel : ViewModel() {
private val repository = MediaRepository.getInstance()
private val _results = MutableStateFlow<List<ContentItem>>(emptyList())
val results: StateFlow<List<ContentItem>> = _results.asStateFlow()
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading.asStateFlow()
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean> = _empty.asStateFlow()
/** 执行搜索。空关键词清空结果。 */
fun search(keyword: String) {
val kw = keyword.trim()
if (kw.isEmpty()) {
_results.value = emptyList()
_empty.value = false
return
}
viewModelScope.launch {
_loading.value = true
repository.search(kw)
.onSuccess { response ->
val list = response.result ?: emptyList()
_results.value = list
_empty.value = list.isEmpty()
}
.onFailure {
_results.value = emptyList()
_empty.value = true
}
_loading.value = false
}
}
}

View File

@@ -0,0 +1,24 @@
/**
* @file ActivationCodeInputView.kt
* @description 激活码输入独立视图,负责键盘输入、提示态与自动提交
* @author ainetv
* @date 2026-03-30
*/
package com.pukka.mediaapp.widget
import android.content.Context
import android.util.AttributeSet
class ActivationCodeInputView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LocationCodeInputView(context, attrs, defStyleAttr) {
init {
setInputMode(InputMode.ACTIVATION)
}
fun setOnInputChangedListener(listener: ((String) -> Unit)?) {
setOnActivationChangedListener(listener)
}
}

View File

@@ -0,0 +1,32 @@
/**
* @file ActivationFocusRecyclerView.java
* @description 激活流程专用焦点 RecyclerView,作为激活页独立类型入口
* @author ainetv
* @date 2026-04-04
*/
package com.pukka.mediaapp.widget;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
public class ActivationFocusRecyclerView extends RecyclerView {
public ActivationFocusRecyclerView(@NonNull Context context) {
super(context);
}
public ActivationFocusRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public ActivationFocusRecyclerView(
@NonNull Context context,
@Nullable AttributeSet attrs,
int defStyleAttr
) {
super(context, attrs, defStyleAttr);
}
}

View File

@@ -0,0 +1,5 @@
package com.pukka.mediaapp.widget
data class ActivationKeyItem(
val value: String
)

View File

@@ -0,0 +1,54 @@
package com.pukka.mediaapp.widget
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.R
class ActivationKeypadAdapter(
private val keys: List<ActivationKeyItem>,
private val onKeyClick: (String) -> Unit
) : RecyclerView.Adapter<ActivationKeypadAdapter.KeyViewHolder>() {
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KeyViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_activation_key, parent, false)
return KeyViewHolder(view)
}
override fun onBindViewHolder(holder: KeyViewHolder, position: Int) {
val item = keys[position]
holder.keyView.text = item.value
holder.itemView.setOnClickListener {
onKeyClick(item.value)
}
holder.itemView.setOnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@setOnKeyListener false
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
onKeyClick(item.value)
return@setOnKeyListener true
}
false
}
}
override fun getItemCount(): Int {
return keys.size
}
override fun getItemId(position: Int): Long {
return keys[position].value.hashCode().toLong()
}
class KeyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val keyView: TextView = itemView.findViewById(R.id.id_tv_key)
}
}

View File

@@ -0,0 +1,744 @@
package com.pukka.mediaapp.widget
import android.app.Activity
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.constraintlayout.motion.widget.MotionLayout
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.BuildConfig
import com.pukka.mediaapp.R
import com.pukka.mediaapp.config.AppConfiguration
import com.pukka.mediaapp.utils.DeviceInfoUtil
class DeviceNotActivatedView @JvmOverloads constructor(
private val mContext: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(
mContext, attrs, defStyleAttr
) {
private val viewTag = "DeviceNotActivatedView"
private var currentInputIndex = INDEX_LOCATION_INPUT
private var pendingFocusInputIndex: Int? = null
private var mUnifiedInputView: LocationCodeInputView? = null
private var mMainMotionLayout: MotionLayout? = null
private var mInputDisplayContainer: ViewGroup? = null
private var mLocationPreviewView: TextView? = null
private var mLocationTipsView: TextView? = null
private var mActivatePreviewView: TextView? = null
private var mActivateBgView : View? = null
private var mActivateTipsView: TextView? = null
private var mActivatePreSuffixView: TextView? = null
private var mActivationInputContainer: ViewGroup? = null
private var mRvDeviceInfo: RecyclerView? = null
private var mDeviceInfoAdapter: DeviceInfoAdapter? = null
private var mOnActivateListener: OnActivateListener? = null
private var mBackButton: LinearLayout? = null
private var mActivatePlaceholderError = false
private var mLastActivationEditableLength = 0
private var mLastLocationEditableLength = 0
private var suppressNextLocationAutoSwitch = false
private var mCurrentErrorCode: Int? = null
private var isActivationMotionEnabled = false
private var isKeyboardInputInteractive = false
interface OnActivateListener {
fun onSubmit(activateCode: String, roomNo: String)
}
init {
initView(mContext)
}
private fun initView(context: Context?) {
LayoutInflater.from(context).inflate(R.layout.device_not_activated_view, this, true)
mMainMotionLayout = findViewById(R.id.id_ml_main_panel)
mActivationInputContainer = findViewById(R.id.id_cl_activation_input_container)
mInputDisplayContainer = findViewById(R.id.id_cl_input_display)
mLocationPreviewView = findViewById(R.id.id_tv_location_placeholder)
mLocationTipsView = findViewById(R.id.id_tv_location_tips)
mActivateTipsView = findViewById(R.id.id_tv_activate_tips)
mActivatePreSuffixView = findViewById(R.id.id_tv_activate_pre)
mActivateBgView = findViewById(R.id.id_v_activate_code_bg)
mActivatePreviewView = findViewById(R.id.id_tv_activate_placeholder)
mUnifiedInputView = findViewById(R.id.id_v_location_input_panel)
mRvDeviceInfo = findViewById<RecyclerView>(R.id.id_rv_device_info)
initInputPanels()
initDeviceInfo()
initLocationCodeInput()
initActivationCodeInput()
initInputDisplayMotion()
initBackButton()
initInputTouchGuard()
// daycard 无渠道前缀,隐藏7位码设计的"AB-"前缀
mActivatePreSuffixView?.visibility = View.GONE
}
private fun initInputTouchGuard() {
mActivationInputContainer?.setOnTouchListener { _, _ ->
!isKeyboardInputInteractive
}
mUnifiedInputView?.setOnTouchListener { _, _ ->
!isKeyboardInputInteractive
}
}
private fun initInputPanels() {
mUnifiedInputView?.setInputMode(LocationCodeInputView.InputMode.LOCATION)
mUnifiedInputView?.setOnNavigateUpToTabListener {
focusInputPreview(currentInputIndex)
}
mUnifiedInputView?.setOnNavigateLeftFallbackListener {
focusInputPreview(currentInputIndex)
}
selectInputPage(INDEX_LOCATION_INPUT)
}
private fun initInputDisplayMotion() {
syncInputPanelEnabledState(false)
syncLocationPlaceholder("")
showLocationDefaultTips()
syncActivationPlaceholder("")
updateTabDisplayState()
applyActivationMotionToggle()
mInputDisplayContainer?.setOnFocusChangeListener { _, hasFocus ->
logDetail("inputDisplay focus changed, hasFocus=$hasFocus, currentState=${mMainMotionLayout?.currentState}")
if (hasFocus) {
return@setOnFocusChangeListener
}
post {
if (!isFocusInInputPanel()) {
logDetail("inputDisplay lost focus and focus not in input panel -> collapseInputPanel")
collapseInputPanel()
}
}
}
mInputDisplayContainer?.setOnClickListener {
enterInputInteraction(INDEX_LOCATION_INPUT)
}
mLocationPreviewView?.setOnClickListener {
enterInputInteraction(INDEX_LOCATION_INPUT)
}
mActivateBgView?.setOnClickListener {
enterInputInteraction(INDEX_ACTIVATE_INPUT)
}
mLocationPreviewView?.setOnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@setOnKeyListener false
}
when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT -> {
enterInputInteraction(INDEX_LOCATION_INPUT)
true
}
else -> false
}
}
mActivateBgView?.setOnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@setOnKeyListener false
}
when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_DOWN -> {
enterInputInteraction(INDEX_ACTIVATE_INPUT)
true
}
else -> false
}
}
mInputDisplayContainer?.setOnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@setOnKeyListener false
}
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER -> {
enterInputInteraction(currentInputIndex)
true
}
else -> false
}
}
mMainMotionLayout?.setTransitionListener(object : MotionLayout.TransitionListener {
override fun onTransitionStarted(motionLayout: MotionLayout?, startId: Int, endId: Int) = Unit
override fun onTransitionChange(
motionLayout: MotionLayout?,
startId: Int,
endId: Int,
progress: Float
) = Unit
override fun onTransitionCompleted(motionLayout: MotionLayout?, currentId: Int) {
logDetail(
"motion transition completed, currentId=$currentId, pendingFocusInputIndex=$pendingFocusInputIndex, currentInputIndex=$currentInputIndex"
)
syncInputPanelEnabledState(currentId == R.id.state_input_focused)
if (currentId == R.id.state_input_focused) {
dispatchPendingInputFocusIfNeeded()
} else {
pendingFocusInputIndex = null
}
}
override fun onTransitionTrigger(
motionLayout: MotionLayout?,
triggerId: Int,
positive: Boolean,
progress: Float
) = Unit
})
}
private fun applyActivationMotionToggle() {
mMainMotionLayout?.setInteractionEnabled(isActivationMotionEnabled)
if (isActivationMotionEnabled) {
syncInputPanelEnabledState(mMainMotionLayout?.currentState == R.id.state_input_focused)
return
}
pendingFocusInputIndex = null
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
mMainMotionLayout?.transitionToState(R.id.state_input_focused)
}
syncInputPanelEnabledState(true)
dispatchInputPageFocus(currentInputIndex, 0)
}
private fun selectInputPage(position: Int) {
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) return
currentInputIndex = position
val mode = if (position == INDEX_LOCATION_INPUT) {
LocationCodeInputView.InputMode.LOCATION
} else {
LocationCodeInputView.InputMode.ACTIVATION
}
if (mode == LocationCodeInputView.InputMode.LOCATION
&& mUnifiedInputView?.getInputMode() != LocationCodeInputView.InputMode.LOCATION
) {
suppressNextLocationAutoSwitch = true
}
mUnifiedInputView?.setInputMode(mode)
updateTabDisplayState()
}
private fun focusInputPreview(position: Int) {
val target = when (position) {
INDEX_LOCATION_INPUT -> mLocationPreviewView
INDEX_ACTIVATE_INPUT -> mActivateBgView
else -> null
}
if (target != null && target.isShown && target.isEnabled) {
target.requestFocus()
return
}
mInputDisplayContainer?.requestFocus()
}
private fun enterInputInteraction(position: Int) {
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) {
return
}
logDetail("enterInputInteraction position=$position, currentState=${mMainMotionLayout?.currentState}")
pendingFocusInputIndex = position
selectInputPage(position)
if (!isActivationMotionEnabled) {
dispatchInputPageFocus(position, 0)
return
}
expandInputPanel(false)
dispatchPendingInputFocusIfNeeded()
}
private fun dispatchPendingInputFocusIfNeeded() {
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
logDetail("dispatchPendingInputFocusIfNeeded skipped, state=${mMainMotionLayout?.currentState}, pending=$pendingFocusInputIndex")
return
}
val focusTarget = pendingFocusInputIndex ?: return
logDetail("dispatchPendingInputFocusIfNeeded target=$focusTarget")
pendingFocusInputIndex = null
dispatchInputPageFocus(focusTarget, 0)
}
private fun dispatchInputPageFocus(position: Int, attempt: Int) {
mActivationInputContainer?.post {
logDetail("dispatchInputPageFocus position=$position, attempt=$attempt")
if (requestInputPageChildFocus(position)) {
logDetail("dispatchInputPageFocus success at attempt=$attempt")
return@post
}
if (attempt >= INPUT_FOCUS_RETRY_COUNT) {
logDetail("dispatchInputPageFocus failed after max attempts, position=$position")
return@post
}
mActivationInputContainer?.postDelayed(
{ dispatchInputPageFocus(position, attempt + 1) },
INPUT_FOCUS_RETRY_DELAY_MS
)
}
}
private fun requestInputPageChildFocus(position: Int): Boolean {
if (position !in INDEX_LOCATION_INPUT..INDEX_ACTIVATE_INPUT) {
return false
}
if (mUnifiedInputView?.requestLeftEntryDefaultFocus() == true) {
return true
}
return requestFocusFromCandidates(
listOf(
mUnifiedInputView?.findViewById(R.id.id_rv_location_keypad),
mUnifiedInputView?.findViewById(R.id.id_tv_location_delete),
mUnifiedInputView?.findViewById(R.id.id_tv_location_clear)
)
)
}
private fun requestFocusFromCandidates(candidates: List<View?>): Boolean {
candidates.forEach { candidate ->
if (requestFocusOnCandidate(candidate)) {
return true
}
}
return false
}
private fun requestFocusOnCandidate(target: View?): Boolean {
if (target == null || !target.isEnabled || !target.isShown) {
return false
}
val hasFocused = target.requestFocus() || target.hasFocus()
if (hasFocused) {
return true
}
val focusedDescendant = target.findFocus()
return focusedDescendant != null && focusedDescendant.isShown
}
private fun expandInputPanel(requestInputFocus: Boolean) {
if (!isActivationMotionEnabled) {
syncInputPanelEnabledState(true)
if (requestInputFocus) {
dispatchInputPageFocus(currentInputIndex, 0)
}
return
}
logDetail("expandInputPanel requestInputFocus=$requestInputFocus, currentState=${mMainMotionLayout?.currentState}")
syncInputPanelEnabledState(true)
if (mMainMotionLayout?.currentState != R.id.state_input_focused) {
mMainMotionLayout?.transitionToEnd()
}
if (requestInputFocus) {
post { enterInputInteraction(currentInputIndex) }
}
}
private fun collapseInputPanel() {
if (!isActivationMotionEnabled) {
syncInputPanelEnabledState(true)
return
}
logDetail("collapseInputPanel currentState=${mMainMotionLayout?.currentState}")
if (mMainMotionLayout?.currentState != R.id.state_default) {
mMainMotionLayout?.transitionToStart()
} else {
syncInputPanelEnabledState(false)
}
}
private fun syncInputPanelEnabledState(enabled: Boolean) {
logDetail("syncInputPanelEnabledState enabled=$enabled")
mActivationInputContainer?.isEnabled = enabled
mUnifiedInputView?.isEnabled = enabled
isKeyboardInputInteractive = enabled
mActivationInputContainer?.descendantFocusability = if (enabled) {
ViewGroup.FOCUS_AFTER_DESCENDANTS
} else {
ViewGroup.FOCUS_BLOCK_DESCENDANTS
}
if (!enabled) {
mActivationInputContainer?.clearFocus()
mUnifiedInputView?.clearFocus()
}
}
private fun isFocusInInputPanel(): Boolean {
val focusedView = findFocus() ?: return false
return focusedView.isDescendantOf(mInputDisplayContainer)
|| focusedView.isDescendantOf(mActivationInputContainer)
}
private fun View.isDescendantOf(parentView: View?): Boolean {
if (parentView == null) {
return false
}
var current: View? = this
while (current != null) {
if (current === parentView) {
return true
}
current = current.parent as? View
}
return false
}
private fun updateTabDisplayState() {
}
private fun initActivationCodeInput() {
mUnifiedInputView?.setOnActivationChangedListener { activationCode ->
val currentLength = activationCode.length
val hasLengthChanged = currentLength != mLastActivationEditableLength
logDetail(
"onActivationChanged codeLength=$currentLength, beforeClearError=$mActivatePlaceholderError, hasLengthChanged=$hasLengthChanged"
)
if (hasLengthChanged) {
clearActivatePlaceholderErrorState()
} else {
logDetail("onActivationChanged ignore clear error because length unchanged")
}
mLastActivationEditableLength = currentLength
syncActivationPlaceholder(activationCode)
}
mUnifiedInputView?.setOnTipChangedListener { message, color ->
logColorDecision(
source = "onTipChanged.activateTips",
view = mActivateTipsView,
targetColor = color,
reason = "tip message changed: $message"
)
mActivateTipsView?.setTextColor(color)
mActivateTipsView?.text = message
}
mUnifiedInputView?.setOnSubmitListener { activateCode ->
val roomNo = mUnifiedInputView?.getLocationCode().orEmpty()
if (mOnActivateListener == null) {
mUnifiedInputView?.setSubmitting(false)
showActivateErrorMessage(context.getString(R.string.str_activate_submit_unavailable))
return@setOnSubmitListener
}
mOnActivateListener?.onSubmit(activateCode, roomNo)
}
mUnifiedInputView?.showDefaultTips()
}
private fun initLocationCodeInput() {
mUnifiedInputView?.setOnLocationChangedListener { locationCode ->
val currentLength = locationCode.length
val isLengthGrowToMax = currentLength == LOCATION_CODE_MAX_LENGTH
&& mLastLocationEditableLength < LOCATION_CODE_MAX_LENGTH
syncLocationPlaceholder(locationCode)
showLocationDefaultTips()
if (suppressNextLocationAutoSwitch) {
logDetail("skip location auto switch once, locationCode=$locationCode")
suppressNextLocationAutoSwitch = false
} else if (isLengthGrowToMax) {
enterInputInteraction(INDEX_ACTIVATE_INPUT)
}
mLastLocationEditableLength = currentLength
}
}
private fun syncLocationPlaceholder(value: String) {
val placeholder = context.getString(R.string.str_location_placeholder)
applyPlaceholderTextState(mLocationPreviewView, value, placeholder)
}
private fun syncActivationPlaceholder(value: String) {
val placeholder = context.getString(R.string.str_activate_placeholder)
logDetail("syncActivationPlaceholder valueBlank=${value.isBlank()}, errorState=$mActivatePlaceholderError")
applyActivatePlaceholderTextState(value, placeholder)
}
private fun showLocationDefaultTips() {
mLocationTipsView?.setTextColor(ContextCompat.getColor(context, R.color.activation_text_secondary_new))
mLocationTipsView?.text = context.getString(R.string.str_location_input_hint)
}
private fun applyPlaceholderTextState(targetView: TextView?, value: String, placeholder: String) {
if (targetView == null) {
return
}
if (value.isBlank()) {
targetView.text = placeholder
val hintColor = ContextCompat.getColor(context, R.color.activation_text_hint)
logColorDecision(
source = "applyPlaceholderTextState.hint",
view = targetView,
targetColor = hintColor,
reason = "placeholder shown"
)
targetView.setTextColor(hintColor)
return
}
targetView.text = value
val normalColor = ContextCompat.getColor(context, R.color.activation_key_text_selector)
logColorDecision(
source = "applyPlaceholderTextState.value",
view = targetView,
targetColor = normalColor,
reason = "value shown"
)
targetView.setTextColor(normalColor)
}
private fun applyActivatePlaceholderTextState(value: String, placeholder: String) {
val targetView = mActivatePreviewView ?: return
if (!mActivatePlaceholderError) {
applyPlaceholderTextState(targetView, value, placeholder)
return
}
targetView.text = if (value.isBlank()) placeholder else value
val textColorResId = R.color.error_tip_red
val targetColor = ContextCompat.getColor(context, textColorResId)
logColorDecision(
source = "applyActivatePlaceholderTextState",
view = targetView,
targetColor = targetColor,
reason = "valueBlank=${value.isBlank()}, errorState=$mActivatePlaceholderError, colorRes=$textColorResId"
)
targetView.setTextColor(targetColor)
}
private fun clearActivatePlaceholderErrorState() {
logDetail("clearActivatePlaceholderErrorState from=$mActivatePlaceholderError to=false")
mActivatePlaceholderError = false
}
private fun showActivatePlaceholderErrorState() {
logDetail("showActivatePlaceholderErrorState from=$mActivatePlaceholderError to=true")
mActivatePlaceholderError = true
syncActivationPlaceholder(mUnifiedInputView?.getActivationEditablePart().orEmpty())
val shakeAnimation = AnimationUtils.loadAnimation(context, R.anim.activation_code_shake)
logDetail("shakeAnimation prepared=${shakeAnimation != null}, startAnimationDisabled=true")
mActivatePreviewView?.clearAnimation()
mActivatePreviewView?.startAnimation(shakeAnimation)
}
private fun logColorDecision(source: String, view: TextView?, targetColor: Int, reason: String) {
logDetail(
"colorDecision source=$source, viewId=${view?.id}, viewHash=${view?.hashCode()}, currentColor=${view?.currentTextColor}, targetColor=$targetColor, reason=$reason"
)
}
private fun logDetail(message: String) {
if (BuildConfig.DEBUG) {
Log.d(viewTag, message)
}
}
private fun initDeviceInfo() {
val mac = DeviceInfoUtil.getDeviceIdentity(mContext!!).mac ?: ""
val channel = AppConfiguration.getInstance().channelId.uppercase()
val deviceInfoItems = listOf(
// DeviceInfoItem(context.getString(R.string.str_device_name), Build.MODEL)
DeviceInfoItem(context.getString(R.string.str_mac_address), mac.uppercase()),
DeviceInfoItem(context.getString(R.string.str_device_id), AppConfiguration.getInstance().getmDeviceUid()),
DeviceInfoItem(context.getString(R.string.str_version_no), BuildConfig.VERSION_NAME),
DeviceInfoItem(context.getString(R.string.str_channel_id), channel),
)
mRvDeviceInfo?.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
mRvDeviceInfo?.setHasFixedSize(true)
mRvDeviceInfo?.itemAnimator = null
mDeviceInfoAdapter = DeviceInfoAdapter(deviceInfoItems)
mRvDeviceInfo?.adapter = mDeviceInfoAdapter
}
fun setOnActivateListener(listener: OnActivateListener?) {
mOnActivateListener = listener
}
fun setSubmitting(submitting: Boolean) {
mUnifiedInputView?.setSubmitting(submitting)
}
fun setActivationLoadingVisible(visible: Boolean) {
mUnifiedInputView?.setSubmitting(visible)
mInputDisplayContainer?.isEnabled = !visible
mInputDisplayContainer?.isClickable = !visible
if (!visible) {
return
}
clearActivatePlaceholderErrorState()
}
fun showActivateMessage(message: String) {
mUnifiedInputView?.showActivateMessage(message)
}
fun showActivateErrorMessage(message: String) {
if (shouldHighlightActivatePlaceholder()) {
showActivatePlaceholderErrorState()
} else {
clearActivatePlaceholderErrorState()
syncActivationPlaceholder(mUnifiedInputView?.getActivationEditablePart().orEmpty())
}
mUnifiedInputView?.showActivateErrorMessage(message)
}
fun switchToActivateInputPage() {
enterInputInteraction(INDEX_ACTIVATE_INPUT)
}
fun setActivationMotionEnabled(enabled: Boolean) {
if (isActivationMotionEnabled == enabled) {
return
}
isActivationMotionEnabled = enabled
applyActivationMotionToggle()
}
fun setErrorInfo(errorInfo: String) {
updateStatusCode(errorInfo)
mUnifiedInputView?.showDefaultTips()
showLocationDefaultTips()
this.visibility = VISIBLE
setSubmitting(false)
}
fun showDefaultTips(){
mUnifiedInputView?.showDefaultTips()
showLocationDefaultTips()
}
fun updateErrorInfo(errorCode: Int) {
updateStatusCode(errorCode.toString())
}
fun updateStatusCode(statusCode: String) {
mCurrentErrorCode = statusCode.toIntOrNull()
logDetail("updateStatusCode statusCode=$statusCode, parsedErrorCode=$mCurrentErrorCode")
mDeviceInfoAdapter?.updateStatusCode(statusCode)
}
private fun shouldHighlightActivatePlaceholder(): Boolean {
return mCurrentErrorCode == ACTIVATE_PLACEHOLDER_HIGHLIGHT_ERROR_CODE
}
private fun initBackButton() {
if (AppConfiguration.getInstance().isTouchMode) {
if (mBackButton == null) {
mBackButton = LinearLayout(context).apply {
id = R.id.id_back_button_floating
orientation = LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER
isFocusable = true
isClickable = true
background = ContextCompat.getDrawable(context, R.drawable.bg_search_back_btn_selector)
val buttonSize = resources.getDimensionPixelSize(R.dimen.qb_px_80)
val marginEnd = resources.getDimensionPixelSize(R.dimen.qb_px_40)
val marginBottom = resources.getDimensionPixelSize(R.dimen.qb_px_40)
val layoutParams = LayoutParams(buttonSize, buttonSize).apply {
addRule(ALIGN_PARENT_BOTTOM)
addRule(ALIGN_PARENT_RIGHT)
setMargins(0, 0, marginEnd, marginBottom)
}
this.layoutParams = layoutParams
val iconSize = resources.getDimensionPixelSize(R.dimen.qb_px_28)
val iconView = ImageView(context).apply {
this.layoutParams = LinearLayout.LayoutParams(iconSize, iconSize)
setImageResource(R.drawable.icon_detail_back_normal)
}
addView(iconView)
setOnClickListener {
(context as? Activity)?.onBackPressed()
}
}
addView(mBackButton)
}
mBackButton?.visibility = View.VISIBLE
}
}
private data class DeviceInfoItem(val label: String, val value: String)
private class DeviceInfoAdapter(
private val items: List<DeviceInfoItem>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var statusCode = ""
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == VIEW_TYPE_INFO) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_device_info, parent, false)
DeviceInfoViewHolder(view)
} else {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_device_info_footer, parent, false)
StatusFooterViewHolder(view)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is DeviceInfoViewHolder && position < items.size) {
val item = items[position]
holder.labelView.text = item.label
holder.valueView.text = item.value
} else if (holder is StatusFooterViewHolder) {
holder.footerView.text = if (statusCode.isEmpty()) "状态码:" else "状态码:$statusCode"
}
}
override fun getItemCount(): Int {
return items.size + 1
}
override fun getItemViewType(position: Int): Int {
return if (position < items.size) VIEW_TYPE_INFO else VIEW_TYPE_FOOTER
}
override fun getItemId(position: Int): Long {
return if (position < items.size) items[position].label.hashCode().toLong() else Long.MAX_VALUE
}
fun updateStatusCode(value: String) {
statusCode = value
notifyItemChanged(items.size)
}
class DeviceInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val labelView: TextView = itemView.findViewById(R.id.id_tv_info_label)
val valueView: TextView = itemView.findViewById(R.id.id_tv_info_value)
}
class StatusFooterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val footerView: TextView = itemView.findViewById(R.id.id_tv_status_footer)
}
companion object {
private const val VIEW_TYPE_INFO = 0
private const val VIEW_TYPE_FOOTER = 1
}
}
companion object {
private const val DEFAULT_ACTIVATION_MOTION_ENABLED = false
private const val ACTIVATE_PLACEHOLDER_HIGHLIGHT_ERROR_CODE = 10500
private const val LOCATION_CODE_MAX_LENGTH = 5
private const val INDEX_LOCATION_INPUT = 0
private const val INDEX_ACTIVATE_INPUT = 1
private const val INPUT_FOCUS_RETRY_COUNT = 8
private const val INPUT_FOCUS_RETRY_DELAY_MS = 50L
}
}

View File

@@ -0,0 +1,440 @@
/**
* @file LocationCodeInputView.kt
* @description 位置编号输入独立视图,复用激活码输入交互模型并支持全量字母数字键盘
* @author ainetv
* @date 2026-03-30
*/
package com.pukka.mediaapp.widget
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.pukka.mediaapp.R
import com.pukka.mediaapp.config.AppConfiguration
open class LocationCodeInputView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
enum class InputMode {
LOCATION,
ACTIVATION
}
private val defaultActivationTips = context.getString(R.string.str_activate_input_hint)
private val channelPrefix = AppConfiguration.getInstance().channelId.uppercase().take(2)
private val maxEditableLength = 5
private val keypadSpanCount = 6
private var mRvKeypad: ActivationFocusRecyclerView? = null
private var mTvDelete: TextView? = null
private var mTvClear: TextView? = null
private var mCurrentMode: InputMode = InputMode.LOCATION
private var mLocationCode = ""
private var mActivationEditablePart = ""
private var mLastSubmittedActivationCode = ""
private var mSubmitting = false
private var mOnModeInputChangedListener: ((InputMode, String) -> Unit)? = null
private var mOnLocationChangedListener: ((String) -> Unit)? = null
private var mOnActivationChangedListener: ((String) -> Unit)? = null
private var mOnSubmitListener: ((String) -> Unit)? = null
private var mOnTipChangedListener: ((String, Int) -> Unit)? = null
private var mOnNavigateUpToTabListener: (() -> Unit)? = null
private var mOnNavigateLeftFallbackListener: (() -> Unit)? = null
init {
LayoutInflater.from(context).inflate(R.layout.view_location_code_input, this, true)
mRvKeypad = findViewById(R.id.id_rv_location_keypad)
mTvDelete = findViewById(R.id.id_tv_location_delete)
mTvClear = findViewById(R.id.id_tv_location_clear)
initCodeAndAction()
initKeypad()
}
private fun initCodeAndAction() {
mTvDelete?.setOnClickListener { deleteLastChar() }
mTvClear?.setOnClickListener { clearAll() }
val actionKeyListener = View.OnKeyListener { targetView, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@OnKeyListener false
}
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
requestFocusToNearestKeypadChild(targetView)
return@OnKeyListener true
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && targetView === mTvDelete) {
return@OnKeyListener dispatchLeftFallback()
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) {
return@OnKeyListener false
}
false
}
mTvDelete?.setOnKeyListener(actionKeyListener)
mTvClear?.setOnKeyListener(actionKeyListener)
}
private fun initKeypad() {
val layoutManager = GridLayoutManager(context, keypadSpanCount)
val spacing = resources.getDimensionPixelSize(R.dimen.qb_px_10)
mRvKeypad?.layoutManager = layoutManager
mRvKeypad?.setItemAnimator(null)
// mRvKeypad?.setPadding(0, 0, 0, spacing)
if (mRvKeypad?.itemDecorationCount == 0) {
mRvKeypad?.addItemDecoration(RightEdgeAlignedGridDecoration(keypadSpanCount, spacing))
}
mRvKeypad?.setOnKeyListener { _, keyCode, event ->
if (event.action != KeyEvent.ACTION_DOWN) {
return@setOnKeyListener false
}
if (keyCode == KeyEvent.KEYCODE_DPAD_UP) {
val focus = mRvKeypad?.focusedChild ?: return@setOnKeyListener false
val position = mRvKeypad?.getChildAdapterPosition(focus) ?: return@setOnKeyListener false
if (position in 0..5) {
mTvDelete?.requestFocus()
return@setOnKeyListener true
}
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
val focus = mRvKeypad?.focusedChild ?: return@setOnKeyListener false
val position = mRvKeypad?.getChildAdapterPosition(focus) ?: return@setOnKeyListener false
if (position % keypadSpanCount == 0) {
return@setOnKeyListener dispatchLeftFallback()
}
}
false
}
applyModeConfiguration(InputMode.LOCATION, emitInputChanged = false)
}
private fun applyModeConfiguration(mode: InputMode, emitInputChanged: Boolean) {
mCurrentMode = mode
val keys = when (mode) {
InputMode.LOCATION -> buildLocationKeys()
InputMode.ACTIVATION -> buildActivationKeys()
}
mRvKeypad?.adapter = ActivationKeypadAdapter(keys) { value ->
appendChar(value)
}
if (mode == InputMode.ACTIVATION) {
showActivateTip(defaultActivationTips)
}
if (emitInputChanged) {
notifyInputChanged()
}
}
fun setInputMode(mode: InputMode) {
if (mCurrentMode == mode) {
notifyInputChanged()
return
}
applyModeConfiguration(mode, emitInputChanged = true)
}
fun getInputMode(): InputMode = mCurrentMode
private fun buildLocationKeys(): List<ActivationKeyItem> {
val allChars = mutableListOf<String>()
('0'..'9').forEach { allChars.add(it.toString()) }
('A'..'Z').forEach { allChars.add(it.toString()) }
return allChars.map { ActivationKeyItem(it) }
}
private fun buildActivationKeys(): List<ActivationKeyItem> {
return listOf(
"A", "B", "C", "D", "E", "F",
"G", "H", "J", "K", "M", "N",
"P", "Q", "R", "S", "T", "U",
"V", "W", "X", "Y", "2", "3",
"4", "5", "6", "7", "8", "9"
).map { ActivationKeyItem(it) }
}
private fun appendChar(value: String) {
if (mSubmitting || currentEditableValue().length >= maxEditableLength) {
return
}
val updated = currentEditableValue() + value
updateEditableValue(updated)
if (mCurrentMode == InputMode.ACTIVATION) {
mLastSubmittedActivationCode = ""
showActivateTip(defaultActivationTips)
if (updated.length == maxEditableLength) {
submitActivateCode(autoSubmit = true)
}
}
notifyInputChanged()
}
private fun deleteLastChar() {
val editableValue = currentEditableValue()
if (mSubmitting || editableValue.isEmpty()) {
return
}
updateEditableValue(editableValue.dropLast(1))
if (mCurrentMode == InputMode.ACTIVATION) {
mLastSubmittedActivationCode = ""
showActivateTip(defaultActivationTips)
}
notifyInputChanged()
}
private fun clearAll() {
if (mSubmitting || currentEditableValue().isEmpty()) {
return
}
updateEditableValue("")
if (mCurrentMode == InputMode.ACTIVATION) {
mLastSubmittedActivationCode = ""
showActivateTip(defaultActivationTips)
}
notifyInputChanged()
}
private fun currentEditableValue(): String {
return if (mCurrentMode == InputMode.LOCATION) mLocationCode else mActivationEditablePart
}
private fun updateEditableValue(value: String) {
if (mCurrentMode == InputMode.LOCATION) {
mLocationCode = value
} else {
mActivationEditablePart = value
}
}
private fun notifyInputChanged() {
val value = currentEditableValue()
mOnModeInputChangedListener?.invoke(mCurrentMode, value)
if (mCurrentMode == InputMode.LOCATION) {
mOnLocationChangedListener?.invoke(value)
} else {
mOnActivationChangedListener?.invoke(value)
}
}
fun setOnModeInputChangedListener(listener: ((InputMode, String) -> Unit)?) {
mOnModeInputChangedListener = listener
}
fun setOnLocationChangedListener(listener: ((String) -> Unit)?) {
mOnLocationChangedListener = listener
}
fun setOnActivationChangedListener(listener: ((String) -> Unit)?) {
mOnActivationChangedListener = listener
}
fun setOnSubmitListener(listener: ((String) -> Unit)?) {
mOnSubmitListener = listener
}
fun setOnTipChangedListener(listener: ((String, Int) -> Unit)?) {
mOnTipChangedListener = listener
}
fun setOnNavigateUpToTabListener(listener: (() -> Unit)?) {
mOnNavigateUpToTabListener = listener
}
fun setOnNavigateLeftFallbackListener(listener: (() -> Unit)?) {
mOnNavigateLeftFallbackListener = listener
}
fun getLocationCode(): String {
return mLocationCode
}
fun setLocationCode(value: String) {
mLocationCode = value.uppercase().filter { (it in 'A'..'Z') || (it in '0'..'9') }.take(maxEditableLength)
if (mCurrentMode == InputMode.LOCATION) {
notifyInputChanged()
}
}
fun getActivationEditablePart(): String {
return mActivationEditablePart
}
fun setActivationEditablePart(value: String) {
mActivationEditablePart = value.uppercase().filter { (it in 'A'..'Z') || (it in '0'..'9') }.take(maxEditableLength)
if (mCurrentMode == InputMode.ACTIVATION) {
notifyInputChanged()
}
}
fun clearInput() {
if (mCurrentMode == InputMode.LOCATION) {
mLocationCode = ""
} else {
mActivationEditablePart = ""
}
notifyInputChanged()
}
fun setSubmitting(submitting: Boolean) {
mSubmitting = submitting
mRvKeypad?.isEnabled = !submitting
mTvDelete?.isEnabled = !submitting
mTvClear?.isEnabled = !submitting
if (mCurrentMode == InputMode.ACTIVATION) {
if (submitting) {
showActivateTip(context.getString(R.string.str_activate_submitting))
} else {
showActivateTip(defaultActivationTips)
}
}
}
fun requestFocusOnFirstKey() {
mRvKeypad?.postDelayed({ mRvKeypad?.requestFocus() }, 500)
}
fun requestLeftEntryDefaultFocus(): Boolean {
return requestFocusOnCandidate(mRvKeypad)
}
private fun dispatchLeftFallback(): Boolean {
val fallbackListener = mOnNavigateLeftFallbackListener ?: return false
fallbackListener.invoke()
return true
}
private fun requestFocusOnCandidate(target: View?): Boolean {
if (target == null || !target.isShown || !target.isEnabled) {
return false
}
if (target.requestFocus() || target.hasFocus()) {
return true
}
val focusedDescendant = target.findFocus()
return focusedDescendant != null && focusedDescendant.isShown
}
private fun requestFocusToNearestKeypadChild(anchorView: View): Boolean {
val recyclerView = mRvKeypad ?: return false
if (!recyclerView.isShown || !recyclerView.isEnabled) {
return false
}
val anchorCenter = IntArray(2)
anchorView.getLocationOnScreen(anchorCenter)
val anchorCenterX = anchorCenter[0] + anchorView.width / 2
val anchorCenterY = anchorCenter[1] + anchorView.height / 2
var nearestChild: View? = null
var minDistance = Long.MAX_VALUE
for (index in 0 until recyclerView.childCount) {
val child = recyclerView.getChildAt(index) ?: continue
if (!child.isShown || !child.isFocusable || !child.isEnabled) {
continue
}
val childCenter = IntArray(2)
child.getLocationOnScreen(childCenter)
val childCenterX = childCenter[0] + child.width / 2
val childCenterY = childCenter[1] + child.height / 2
val dx = (childCenterX - anchorCenterX).toLong()
val dy = (childCenterY - anchorCenterY).toLong()
val distance = dx * dx + dy * dy
if (distance < minDistance) {
minDistance = distance
nearestChild = child
}
}
return nearestChild?.requestFocus() == true
}
fun submitActivateCodeManually() {
submitActivateCode(autoSubmit = false)
}
fun showActivateMessage(message: String) {
showActivateTip(message)
}
fun showActivateErrorMessage(message: String) {
showActivateTip(message, R.color.error_tip_red)
}
fun showDefaultTips() {
showActivateTip(defaultActivationTips)
}
private fun showActivateTip(message: String) {
showActivateTip(message, R.color.activation_text_secondary_new)
}
private fun showActivateTip(message: String, colorResId: Int) {
val color = ContextCompat.getColor(context, colorResId)
mOnTipChangedListener?.invoke(message, color)
}
private fun submitActivateCode(autoSubmit: Boolean) {
if (mSubmitting) {
return
}
val inputCode = normalizeCode(channelPrefix + mActivationEditablePart)
if (inputCode.length < maxEditableLength) {
if (!autoSubmit) {
showActivateErrorMessage(context.getString(R.string.str_activate_code_7_tip))
}
return
}
if (autoSubmit && inputCode == mLastSubmittedActivationCode) {
return
}
mLastSubmittedActivationCode = inputCode
setSubmitting(true)
if (mOnSubmitListener == null) {
setSubmitting(false)
showActivateErrorMessage(context.getString(R.string.str_activate_submit_unavailable))
return
}
mOnSubmitListener?.invoke(inputCode)
}
private fun normalizeCode(value: String): String {
if (value.isEmpty()) {
return ""
}
val upperValue = value.uppercase()
val filtered = StringBuilder()
upperValue.forEach { ch ->
if ((ch in 'A'..'Z') || (ch in '0'..'9')) {
filtered.append(ch)
}
}
return if (filtered.length > maxEditableLength) filtered.substring(0, maxEditableLength) else filtered.toString()
}
private class RightEdgeAlignedGridDecoration(
private val spanCount: Int,
private val spacing: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) {
return
}
val column = position % spanCount
outRect.left = column * spacing / spanCount
outRect.right = spacing - (column + 1) * spacing / spanCount
outRect.bottom = spacing
}
}
}

View File

@@ -0,0 +1,40 @@
/**
* @file SquareButton.java
* @description 方形按钮文本控件,统一实现以宽定高的测量规则
* @author ainetv
* @date 2026-04-03
*/
package com.pukka.mediaapp.widget;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
public class SquareTextView extends AppCompatTextView {
public SquareTextView(@NonNull Context context) {
super(context);
}
public SquareTextView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public SquareTextView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int size = getMeasuredWidth();
if (size <= 0) {
size = MeasureSpec.getSize(widthMeasureSpec);
}
int squareSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
super.onMeasure(squareSpec, squareSpec);
}
}

View File

@@ -0,0 +1,429 @@
package com.pukka.mediaapp.widget;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextClock;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.facebook.common.executors.CallerThreadExecutor;
import com.facebook.common.references.CloseableReference;
import com.facebook.datasource.BaseDataSubscriber;
import com.facebook.datasource.DataSource;
import com.facebook.drawee.backends.pipeline.Fresco;
import com.facebook.drawee.controller.ControllerListener;
import com.facebook.drawee.interfaces.DraweeController;
import com.facebook.drawee.view.SimpleDraweeView;
import com.facebook.imagepipeline.image.CloseableImage;
import com.facebook.imagepipeline.image.ImageInfo;
import com.facebook.imagepipeline.request.ImageRequest;
import com.facebook.imagepipeline.request.ImageRequestBuilder;
import com.migu.bean.GetTopButtonBean;
import com.pukka.mediaapp.R;
import com.pukka.mediaapp.utils.TopStyleUtils;
import com.pukka.libcore.boxShared.BoxSharedParamsUtils;
import com.pukka.libcore.fresco.PkSimpleDraweeView;
import com.pukka.libcore.utils.ContextUtils;
import com.pukka.libcore.utils.LogUtils;
public class TopView extends ConstraintLayout implements View.OnClickListener, View.OnFocusChangeListener {
public PkSimpleDraweeView mIvLogo;
private SimpleDraweeView mIvSearch;
private SimpleDraweeView mIvHistory;
private SimpleDraweeView mIvOrder;
private SimpleDraweeView mIvService;
private SimpleDraweeView mIvHelp;
private SimpleDraweeView mIvSetting;
private SimpleDraweeView mIvCollect;
//private TextView mAmtNotice;
//private TextClock mTextClock;
// private ConstraintLayout mIvSearch, mIvHistory, mClOrder, mClService, mClHelp, mClSetting, mClCollect;
// private TextView mTsearch, mThistory, mTorder, mThelp, mTservice, mTsetting, mTcollect;
private String focusSearchImg, focusHistoryImg, focusOrderImg, focusHelpImg, focusServiceImg, focusSettingImg, focusCollectImg;
private String noSearchImg, noHistoryImg, noOrderImg, noHelpImg, noServiceImg, noSettingImg, noCollectImg;
/**
* 所处的页面类型(首页或二级页)
*/
private String pageType;
public TopView(@NonNull Context context) {
super(context);
initView(context);
}
public TopView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public TopView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
/**
* 设置所处的页面类型(首页或二级页)
*/
public void setPageType(String pageType) {
this.pageType = pageType;
}
private void initView(Context context) {
LayoutInflater.from(context).inflate(R.layout.top_view, this, true);
mIvLogo = findViewById(R.id.id_iv_logo);
mIvLogo.requestFocus();
mIvSearch = findViewById(R.id.id_iv_search);
mIvSearch.getHierarchy().setFadeDuration(0);
mIvSearch.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
mIvHistory = findViewById(R.id.id_iv_history);
mIvHistory.getHierarchy().setFadeDuration(0);
mIvHistory.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
mIvOrder = findViewById(R.id.id_iv_order);
mIvOrder.getHierarchy().setFadeDuration(0);
mIvOrder.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
mIvService = findViewById(R.id.id_iv_service);
mIvService.getHierarchy().setFadeDuration(0);
mIvService.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
mIvHelp = findViewById(R.id.id_iv_help);
mIvHelp.getHierarchy().setFadeDuration(0);
mIvHelp.getHierarchy().setPlaceholderImage(new ColorDrawable(Color.TRANSPARENT));
//mAmtNotice = findViewById(R.id.id_atv_notice);
mIvSearch.setOnFocusChangeListener(this);
mIvSearch.setOnClickListener(this);
mIvHistory.setOnFocusChangeListener(this);
mIvHistory.setOnClickListener(this);
mIvOrder.setOnFocusChangeListener(this);
mIvOrder.setOnClickListener(this);
mIvService.setOnFocusChangeListener(this);
mIvService.setOnClickListener(this);
mIvHelp.setOnFocusChangeListener(this);
mIvHelp.setOnClickListener(this);
mIvSetting = findViewById(R.id.id_iv_setting);
mIvSetting.setOnFocusChangeListener(this);
mIvSetting.setOnClickListener(this);
mIvCollect = findViewById(R.id.id_iv_collect);
mIvCollect.setOnFocusChangeListener(this);
mIvCollect.setOnClickListener(this);
//mTextClock = findViewById(R.id.id_tc_clock);
mIvHelp.setNextFocusRightId(R.id.id_iv_help);
}
public interface OnTopButtonClickListener {
void onSearchClick();
void onHistoryClick();
void onOrderClick();
void onHelpClick();
void onServiceClick();
void onSettingClick();
void onCollectClick();
}
private OnTopButtonClickListener clickListener;
public void setOnTopButtonClickListener(OnTopButtonClickListener listener) {
this.clickListener = listener;
}
@Override
public void onClick(View view) {
view.requestFocusFromTouch();
if (clickListener == null) return;
int id = view.getId();
if (id == R.id.id_iv_search) clickListener.onSearchClick();
else if (id == R.id.id_iv_history) clickListener.onHistoryClick();
else if (id == R.id.id_iv_order) clickListener.onOrderClick();
else if (id == R.id.id_iv_help) clickListener.onHelpClick();
else if (id == R.id.id_iv_service) clickListener.onServiceClick();
else if (id == R.id.id_iv_setting) clickListener.onSettingClick();
else if (id == R.id.id_iv_collect) clickListener.onCollectClick();
}
public void bindData(GetTopButtonBean response) {
if (response != null && response.getData() != null && response.getData().size() > 0) {
// 判定TOP数据类别并使用相关数据渲染界面
for (int i = 0; i < response.getData().size(); i++) {
int type = response.getData().get(i).getType();
switch (type) {
case 1:
setLogo(response.getData().get(i).getBack_url());
break;
case 2:
// String message = response.getData().get(i).getMessage();
// if (!TextUtils.isEmpty(message)) {
// mAmtNotice.setText(message);
// mAmtNotice.setVisibility(View.VISIBLE);
// }
break;
case 3:
mIvSearch.setVisibility(View.VISIBLE);
mIvSearch.setImageURI(response.getData().get(i).getBack_url());
noSearchImg = response.getData().get(i).getBack_url();
focusSearchImg = response.getData().get(i).getFocus_picurl();
loadImgPre(focusSearchImg, ContextUtils.getContext());
break;
case 4:
mIvHistory.setVisibility(View.VISIBLE);
mIvHistory.setImageURI(response.getData().get(i).getBack_url());
noHistoryImg = response.getData().get(i).getBack_url();
focusHistoryImg = response.getData().get(i).getFocus_picurl();
loadImgPre(focusHistoryImg,ContextUtils.getContext());
break;
case 5:
if(BoxSharedParamsUtils.isJcHotel()){
break;
}
mIvOrder.setVisibility(View.VISIBLE);
mIvOrder.setImageURI(response.getData().get(i).getBack_url());
noOrderImg = response.getData().get(i).getBack_url();
focusOrderImg = response.getData().get(i).getFocus_picurl();
loadImgPre(focusOrderImg,ContextUtils.getContext());
break;
case 6:
mIvHelp.setVisibility(View.VISIBLE);
mIvHelp.setImageURI(response.getData().get(i).getBack_url());
noHelpImg = response.getData().get(i).getBack_url();
focusHelpImg = response.getData().get(i).getFocus_picurl();
loadImgPre(focusHelpImg,ContextUtils.getContext());
break;
case 7:
mIvService.setVisibility(View.VISIBLE);
mIvService.setImageURI(response.getData().get(i).getBack_url());
noServiceImg = response.getData().get(i).getBack_url();
focusServiceImg = response.getData().get(i).getFocus_picurl();
break;
case 8:
mIvSetting.setVisibility(View.VISIBLE);
mIvSetting.setImageURI(response.getData().get(i).getBack_url());
noSettingImg = response.getData().get(i).getBack_url();
focusSettingImg = response.getData().get(i).getFocus_picurl();
break;
case 9:
mIvCollect.setVisibility(View.VISIBLE);
mIvCollect.setImageURI(response.getData().get(i).getBack_url());
noCollectImg = response.getData().get(i).getBack_url();
focusCollectImg = response.getData().get(i).getFocus_picurl();
break;
default:
break;
}
}
if (mIvCollect.getVisibility() == GONE) {
if (mIvSetting.getVisibility() == GONE) {
if (mIvService.getVisibility() == GONE) {
if (mIvOrder.getVisibility() == GONE) {
if (mIvHelp.getVisibility() == GONE) {
if (mIvHistory.getVisibility() == GONE) {
if (mIvSearch.getVisibility() == VISIBLE) {
mIvSearch.setNextFocusRightId(R.id.id_iv_search);
}
} else {
mIvHistory.setNextFocusRightId(R.id.id_iv_history);
}
} else {
mIvHelp.setNextFocusRightId(R.id.id_iv_order);
}
} else {
mIvOrder.setNextFocusRightId(R.id.id_iv_help);
}
} else {
mIvService.setNextFocusRightId(R.id.id_iv_service);
}
} else {
mIvSetting.setNextFocusRightId(R.id.id_iv_setting);
}
}
if (mIvSearch.getVisibility() == GONE) {
if (mIvHistory.getVisibility() == GONE) {
if (mIvOrder.getVisibility() == GONE) {
if (mIvHelp.getVisibility() == GONE) {
if (mIvService.getVisibility() == GONE) {
if (mIvSetting.getVisibility() == GONE) {
if (mIvCollect.getVisibility() == VISIBLE) {
mIvCollect.setNextFocusRightId(R.id.id_iv_collect);
}
} else {
mIvSetting.setNextFocusLeftId(R.id.id_iv_setting);
}
} else {
mIvService.setNextFocusLeftId(R.id.id_iv_service);
}
} else {
mIvHelp.setNextFocusLeftId(R.id.id_iv_help);
}
} else {
mIvOrder.setNextFocusLeftId(R.id.id_iv_order);
}
} else {
mIvHistory.setNextFocusLeftId(R.id.id_iv_history);
}
}
} else {
//mTextClock.setVisibility(GONE);
}
}
@Override
public void onFocusChange(View view, boolean b) {
if (b) {
if (view.getId() == R.id.id_iv_search) {
if (!TextUtils.isEmpty(focusSearchImg)) {
mIvSearch.setImageURI(focusSearchImg);
}
}
if (view.getId() == R.id.id_iv_history) {
if (!TextUtils.isEmpty(focusHistoryImg)) {
mIvHistory.setImageURI(focusHistoryImg);
}
}
if (view.getId() == R.id.id_iv_order) {
if (!TextUtils.isEmpty(focusOrderImg)) {
mIvOrder.setImageURI(focusOrderImg);
}
}
if (view.getId() == R.id.id_iv_help) {
if (!TextUtils.isEmpty(focusHelpImg)) {
mIvHelp.setImageURI(focusHelpImg);
}
}
if (view.getId() == R.id.id_iv_service) {
if (!TextUtils.isEmpty(focusServiceImg)) {
mIvService.setImageURI(focusServiceImg);
}
}
if (view.getId() == R.id.id_iv_setting) {
if (!TextUtils.isEmpty(focusSettingImg)) {
mIvSetting.setImageURI(focusSettingImg);
}
}
if (view.getId() == R.id.id_iv_collect) {
if (!TextUtils.isEmpty(focusCollectImg)) {
mIvCollect.setImageURI(focusCollectImg);
}
}
} else {
if (view.getId() == R.id.id_iv_search) {
mIvSearch.setImageURI(noSearchImg);
}
if (view.getId() == R.id.id_iv_history) {
mIvHistory.setImageURI(noHistoryImg);
}
if (view.getId() == R.id.id_iv_order) {
mIvOrder.setImageURI(noOrderImg);
}
if (view.getId() == R.id.id_iv_help) {
mIvHelp.setImageURI(noHelpImg);
}
if (view.getId() == R.id.id_iv_service) {
mIvService.setImageURI(noServiceImg);
}
if (view.getId() == R.id.id_iv_setting) {
mIvSetting.setImageURI(noSettingImg);
}
if (view.getId() == R.id.id_iv_collect) {
mIvCollect.setImageURI(noCollectImg);
}
}
TopStyleUtils.topStyleChange(view.getContext(), view, b);
}
/**
* 设置logo
*
* @param logoUrl logo地址
*/
private void setLogo(String logoUrl) {
if (TextUtils.isEmpty(logoUrl)) {
mIvLogo.setVisibility(View.INVISIBLE);
return;
}
// DraweeController controller = Fresco.newDraweeControllerBuilder()
// .setOldController(mIvLogo.getController())
// .setControllerListener(new ControllerListener<ImageInfo>() {
// @Override
// public void onSubmit(String id, Object callerContext) {
//
// }
//
// @Override
// public void onFinalImageSet(String id, ImageInfo imageInfo, Animatable animatable) {
// if(mIvLogo.getHeight()==0){
// mIvLogo.setVisibility(View.VISIBLE);
// mIvLogo.post(new Runnable() {
// @Override
// public void run() {
// float bl = (float) mIvLogo.getHeight() / imageInfo.getHeight();
// adjustSdv(imageInfo.getWidth() * bl, imageInfo.getHeight() * bl);
// }
// });
// }else {
// float bl = (float) mIvLogo.getHeight() / imageInfo.getHeight();
// adjustSdv(imageInfo.getWidth() * bl, imageInfo.getHeight() * bl);
// }
// }
//
// @Override
// public void onIntermediateImageSet(String id, ImageInfo imageInfo) {
//
// }
//
// @Override
// public void onIntermediateImageFailed(String id, Throwable throwable) {
// }
//
// @Override
// public void onFailure(String id, Throwable throwable) {
// }
//
// @Override
// public void onRelease(String id) {
//
// }
// })
// .setUri(Uri.parse(logoUrl))
// .build();
//
// mIvLogo.setController(controller);
mIvLogo.setImageURI(logoUrl);
mIvLogo.setVisibility(View.VISIBLE);
}
private void adjustSdv(float width, float height) {
LayoutParams params = (LayoutParams) mIvLogo.getLayoutParams();
params.width = (int) width;
params.height = (int) height;
mIvLogo.setLayoutParams(params);
}
/**
* 预加载图片到缓存(不显示)
* */
private void loadImgPre(String imgUrl,Context context){
ImageRequest request = ImageRequestBuilder.newBuilderWithSource(Uri.parse(imgUrl))
.setCacheChoice(ImageRequest.CacheChoice.DEFAULT)
.build();
Fresco.getImagePipeline().prefetchToBitmapCache(request, context);
}
/**
* 判断图片是否已经缓存
* */
public boolean isCached(String imageUrl) {
ImageRequest request = ImageRequest.fromUri(imageUrl);
return Fresco.getImagePipeline().isInBitmapMemoryCache(request);
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:fillAfter="false">
<translate
android:duration="50"
android:fromXDelta="-4"
android:interpolator="@android:anim/linear_interpolator"
android:repeatCount="4"
android:repeatMode="reverse"
android:toXDelta="4" />
</set>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 点击态:颜色稍微变暗提供反馈 -->
<item android:state_focused="true" android:color="@color/white" />
<item android:state_pressed="true" android:color="@color/white" />
<item android:color="@color/activation_text_secondary_new" />
</selector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:color="@color/white" />
<item android:state_focused="true" android:color="@color/white" />
<item android:state_selected="true" android:color="@color/white" />
<item android:color="@color/black" />
</selector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:color="@color/white" />
<item android:state_focused="true" android:color="@color/white" />
<item android:state_selected="true" android:color="@color/white" />
<item android:color="@color/activation_text_primary_new" />
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 B

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<gradient
android:startColor="@color/activation_action_focus_start"
android:endColor="@color/activation_action_focus_end"
android:angle="45" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="2dp" android:color="@color/white" />
</shape>
</item>
<!-- 焦点态 -->
<item android:state_focused="true" android:state_pressed="true">
<shape android:shape="rectangle">
<gradient
android:startColor="@color/activation_action_focus_start"
android:endColor="@color/activation_action_focus_end"
android:angle="45" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="2dp" android:color="@color/white" />
</shape>
</item>
<!-- 普通态 -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/activation_action_bg" />
<corners android:radius="@dimen/qb_px_8" />
<!-- 柔和的边框 -->
<stroke android:width="1dp" android:color="@color/activation_border_default" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/activation_bg_card" />
<corners android:radius="@dimen/qb_px_16" />
<!-- <stroke-->
<!-- android:width="1dp"-->
<!-- android:color="@color/activation_border_blue" />-->
</shape>
</item>
<item
android:top="0dp"
android:bottom="2dp">
<shape android:shape="rectangle">
<gradient
android:startColor="#1A007BFF"
android:endColor="#00007BFF"
android:angle="90" />
<corners android:radius="@dimen/qb_px_16" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#80FFFFFF" />
<corners android:radius="@dimen/qb_px_16" />
<stroke android:width="1dp" android:color="@color/activation_border_default" />
</shape>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:useLevel="false">
<solid android:color="#BBDDFF" />
<gradient android:type="linear"
android:startColor="#F7D4D3"
android:centerColor="#FBD8CB"
android:endColor="#FFE1B1"
android:angle="135" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/white" />
<corners android:radius="12dp" />
<stroke
android:width="1dp"
android:color="@color/activation_border_default" />
</shape>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 点击态 (同时拥有焦点和按下状态,修复TV端点击无反馈问题) -->
<item android:state_pressed="true">
<shape android:shape="rectangle">
<gradient
android:startColor="@color/btn_selector_startColor"
android:endColor="@color/btn_selector_endColor"
android:angle="45" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="2dp" android:color="@color/white" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<gradient
android:startColor="@color/btn_selector_startColor"
android:endColor="@color/btn_selector_endColor"
android:angle="45" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="1dp" android:color="@color/white" />
</shape>
</item>
<item android:state_focused="true" >
<shape android:shape="rectangle">
<gradient
android:startColor="@color/btn_selector_startColor"
android:endColor="@color/btn_selector_endColor"
android:angle="45" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="2dp" android:color="@color/white" />
</shape>
</item>
<!-- 普通态 -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/activation_bg_card" />
<corners android:radius="@dimen/qb_px_8" />
<stroke android:width="1dp" android:color="@color/activation_border_default" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#D9202020" />
<corners android:radius="@dimen/qb_px_12" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#FF8E26"
android:endColor="#FF6A00"
android:angle="0" />
<corners android:radius="20dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#22FFFFFF" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#1E1E36"
android:endColor="#15152A"
android:angle="270" />
<corners android:radius="10dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#252542" />
<corners android:radius="10dp" />
<stroke android:width="2dp" android:color="@color/accent" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:startColor="#00000000"
android:endColor="#CC000000" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<gradient android:startColor="#ffdeea" android:centerColor="#fff3ed" android:endColor="#bbd9ff" android:angle="315"/>
<corners android:radius="@dimen/qb_px_6"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#1E1E36"
android:endColor="#15152A"
android:angle="270" />
<corners android:radius="16dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F5F5F5" />
<corners android:radius="@dimen/qb_px_16" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<gradient android:startColor="@color/btn_selector_startColor" android:endColor="@color/btn_selector_endColor" android:angle="315"/>
<corners android:radius="@dimen/qb_px_32"/>
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<gradient android:startColor="@color/btn_selector_startColor" android:endColor="@color/btn_selector_endColor" android:angle="315"/>
<corners android:radius="@dimen/qb_px_32"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/list_bg_tag_focus" android:state_focused="true"/>
<item android:drawable="@drawable/list_bg_tag_unfocus" android:state_selected="true"/>
<item android:drawable="@color/transparent"/>
</selector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:bottom="0dp" android:left="0dp" android:right="0dp" android:top="0dp">
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
<item android:gravity="bottom">
<shape android:shape="rectangle">
<size android:height="3dp" />
<solid android:color="@color/accent" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#33FF7417" />
<corners android:radius="20dp" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#FF8E26"
android:endColor="#FF6A00"
android:angle="0" />
<corners android:radius="24dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_card" />
<corners android:topLeftRadius="10dp" android:topRightRadius="10dp"
android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners android:radius="8dp" />
</shape>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="oval">
<gradient android:startColor="@color/btn_selector_startColor"
android:endColor="@color/btn_selector_endColor"
android:angle="315"/>
</shape>
</item>
<item>
<shape android:shape="oval">
<solid android:color="#33FFFFFF"/>
</shape>
</item>
</selector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1A1A2E" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="@color/divider" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/accent" />
<corners android:radius="2dp" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/vip_bg" />
<corners android:radius="4dp" />
</shape>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:useLevel="false">
<gradient android:type="linear"
android:startColor="#00000000"
android:endColor="#CC000000"
android:angle="270" />
<corners
android:bottomLeftRadius="@dimen/qb_px_6"
android:bottomRightRadius="@dimen/qb_px_6"/>
</shape>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/text_primary">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>

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>

Some files were not shown because too many files have changed in this diff Show More