初始化橘视厅酒店版Android项目
This commit is contained in:
34
.gitignore
vendored
Executable file
34
.gitignore
vendored
Executable 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
4
MediaApp/build.gradle
Normal 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
|
||||||
|
}
|
||||||
15
MediaApp/gradle.properties
Normal file
15
MediaApp/gradle.properties
Normal 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
BIN
MediaApp/gradle/wrapper/gradle-wrapper.jar
vendored
Executable file
Binary file not shown.
6
MediaApp/gradle/wrapper/gradle-wrapper.properties
vendored
Executable file
6
MediaApp/gradle/wrapper/gradle-wrapper.properties
vendored
Executable 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
185
MediaApp/gradlew
vendored
Executable 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
89
MediaApp/gradlew.bat
vendored
Executable 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
25
MediaApp/settings.gradle
Normal 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'
|
||||||
353
MediaApp核心业务逻辑文档.md
Normal file
353
MediaApp核心业务逻辑文档.md
Normal 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×tamp&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
93
README.md
Executable 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
139
app/build.gradle
Normal 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'
|
||||||
|
}
|
||||||
BIN
app/libs/JetsenxSdk-api-future-release-v1.23.aar
Executable file
BIN
app/libs/JetsenxSdk-api-future-release-v1.23.aar
Executable file
Binary file not shown.
BIN
app/libs/NewTVSDK-v4.0.3-app-lite.jar
Executable file
BIN
app/libs/NewTVSDK-v4.0.3-app-lite.jar
Executable file
Binary file not shown.
BIN
app/libs/arm64-v8a/libnewtvsdk.so
Executable file
BIN
app/libs/arm64-v8a/libnewtvsdk.so
Executable file
Binary file not shown.
BIN
app/libs/armeabi-v7a/libnewtvsdk.so
Executable file
BIN
app/libs/armeabi-v7a/libnewtvsdk.so
Executable file
Binary file not shown.
5
app/proguard-rules.pro
vendored
Normal file
5
app/proguard-rules.pro
vendored
Normal 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>;
|
||||||
|
}
|
||||||
56
app/src/main/AndroidManifest.xml
Normal file
56
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
41
app/src/main/java/com/pukka/mediaapp/MediaApp.kt
Normal file
41
app/src/main/java/com/pukka/mediaapp/MediaApp.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/src/main/java/com/pukka/mediaapp/config/ApiConfig.kt
Normal file
39
app/src/main/java/com/pukka/mediaapp/config/ApiConfig.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/src/main/java/com/pukka/mediaapp/config/DayCardConfig.kt
Normal file
45
app/src/main/java/com/pukka/mediaapp/config/DayCardConfig.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/src/main/java/com/pukka/mediaapp/model/Models.kt
Normal file
234
app/src/main/java/com/pukka/mediaapp/model/Models.kt
Normal 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
|
||||||
|
)
|
||||||
@@ -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 = ""
|
||||||
|
)
|
||||||
29
app/src/main/java/com/pukka/mediaapp/network/ApiService.kt
Normal file
29
app/src/main/java/com/pukka/mediaapp/network/ApiService.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
347
app/src/main/java/com/pukka/mediaapp/network/DayCardSignUtil.kt
Normal file
347
app/src/main/java/com/pukka/mediaapp/network/DayCardSignUtil.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/src/main/java/com/pukka/mediaapp/network/MediaRepository.kt
Normal file
122
app/src/main/java/com/pukka/mediaapp/network/MediaRepository.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/main/java/com/pukka/mediaapp/network/SignUtil.kt
Normal file
48
app/src/main/java/com/pukka/mediaapp/network/SignUtil.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
282
app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt
Normal file
282
app/src/main/java/com/pukka/mediaapp/sdk/JetsenxSdkUtil.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
544
app/src/main/java/com/pukka/mediaapp/ui/DetailActivity.kt
Normal file
544
app/src/main/java/com/pukka/mediaapp/ui/DetailActivity.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/src/main/java/com/pukka/mediaapp/ui/HomeActivity.kt
Normal file
139
app/src/main/java/com/pukka/mediaapp/ui/HomeActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/src/main/java/com/pukka/mediaapp/ui/ListActivity.kt
Normal file
97
app/src/main/java/com/pukka/mediaapp/ui/ListActivity.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/src/main/java/com/pukka/mediaapp/ui/SearchActivity.kt
Normal file
75
app/src/main/java/com/pukka/mediaapp/ui/SearchActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt
Normal file
169
app/src/main/java/com/pukka/mediaapp/ui/VideoPlayerActivity.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
237
app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt
Normal file
237
app/src/main/java/com/pukka/mediaapp/utils/DeviceInfoUtil.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt
Normal file
105
app/src/main/java/com/pukka/mediaapp/utils/FileLogger.kt
Normal 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)
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/com/pukka/mediaapp/utils/QRCodeUtil.kt
Normal file
40
app/src/main/java/com/pukka/mediaapp/utils/QRCodeUtil.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.pukka.mediaapp.widget
|
||||||
|
|
||||||
|
data class ActivationKeyItem(
|
||||||
|
val value: String
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
app/src/main/java/com/pukka/mediaapp/widget/TopView.java
Normal file
429
app/src/main/java/com/pukka/mediaapp/widget/TopView.java
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
11
app/src/main/res/anim/activation_code_shake.xml
Normal file
11
app/src/main/res/anim/activation_code_shake.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
7
app/src/main/res/color/activation_key_text_selector.xml
Normal file
7
app/src/main/res/color/activation_key_text_selector.xml
Normal 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>
|
||||||
7
app/src/main/res/color/activation_view_text_selector.xml
Normal file
7
app/src/main/res/color/activation_view_text_selector.xml
Normal 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>
|
||||||
BIN
app/src/main/res/drawable-hdpi/gamepad.png
Executable file
BIN
app/src/main/res/drawable-hdpi/gamepad.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
app/src/main/res/drawable-hdpi/index_bg.png
Executable file
BIN
app/src/main/res/drawable-hdpi/index_bg.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg.png
Executable file
BIN
app/src/main/res/drawable-hdpi/list_bg.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_focus.png
Executable file
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_focus.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_unfocus.png
Executable file
BIN
app/src/main/res/drawable-hdpi/list_bg_tag_unfocus.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 610 B |
BIN
app/src/main/res/drawable-hdpi/list_search_ic.png
Executable file
BIN
app/src/main/res/drawable-hdpi/list_search_ic.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 863 B |
35
app/src/main/res/drawable/bg_activation_action_selector.xml
Normal file
35
app/src/main/res/drawable/bg_activation_action_selector.xml
Normal 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>
|
||||||
23
app/src/main/res/drawable/bg_activation_code_box.xml
Normal file
23
app/src/main/res/drawable/bg_activation_code_box.xml
Normal 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>
|
||||||
7
app/src/main/res/drawable/bg_activation_device_panel.xml
Normal file
7
app/src/main/res/drawable/bg_activation_device_panel.xml
Normal 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>
|
||||||
11
app/src/main/res/drawable/bg_activation_gradient.xml
Normal file
11
app/src/main/res/drawable/bg_activation_gradient.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
46
app/src/main/res/drawable/bg_activation_key_selector.xml
Normal file
46
app/src/main/res/drawable/bg_activation_key_selector.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
9
app/src/main/res/drawable/bg_button_accent.xml
Normal file
9
app/src/main/res/drawable/bg_button_accent.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_button_secondary.xml
Normal file
6
app/src/main/res/drawable/bg_button_secondary.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/bg_card.xml
Normal file
9
app/src/main/res/drawable/bg_card.xml
Normal 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>
|
||||||
7
app/src/main/res/drawable/bg_card_focus.xml
Normal file
7
app/src/main/res/drawable/bg_card_focus.xml
Normal 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>
|
||||||
8
app/src/main/res/drawable/bg_card_gradient_bottom.xml
Normal file
8
app/src/main/res/drawable/bg_card_gradient_bottom.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/bg_detail_def_selector.xml
Normal file
9
app/src/main/res/drawable/bg_detail_def_selector.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/bg_dialog.xml
Normal file
9
app/src/main/res/drawable/bg_dialog.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_dialog_activation.xml
Normal file
6
app/src/main/res/drawable/bg_dialog_activation.xml
Normal 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>
|
||||||
15
app/src/main/res/drawable/bg_filter_table_page_selector.xml
Normal file
15
app/src/main/res/drawable/bg_filter_table_page_selector.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_list_tag_selector.xml
Normal file
6
app/src/main/res/drawable/bg_list_tag_selector.xml
Normal 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>
|
||||||
14
app/src/main/res/drawable/bg_nav_item_selected.xml
Normal file
14
app/src/main/res/drawable/bg_nav_item_selected.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_nav_selected.xml
Normal file
6
app/src/main/res/drawable/bg_nav_selected.xml
Normal 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>
|
||||||
9
app/src/main/res/drawable/bg_play_button.xml
Normal file
9
app/src/main/res/drawable/bg_play_button.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_poster_rounded.xml
Normal file
6
app/src/main/res/drawable/bg_poster_rounded.xml
Normal 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>
|
||||||
7
app/src/main/res/drawable/bg_poster_rounded_top.xml
Normal file
7
app/src/main/res/drawable/bg_poster_rounded_top.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_qrcode_white.xml
Normal file
6
app/src/main/res/drawable/bg_qrcode_white.xml
Normal 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>
|
||||||
15
app/src/main/res/drawable/bg_search_back_btn_selector.xml
Normal file
15
app/src/main/res/drawable/bg_search_back_btn_selector.xml
Normal 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>
|
||||||
7
app/src/main/res/drawable/bg_search_input.xml
Normal file
7
app/src/main/res/drawable/bg_search_input.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_section_bar.xml
Normal file
6
app/src/main/res/drawable/bg_section_bar.xml
Normal 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>
|
||||||
6
app/src/main/res/drawable/bg_vip_tag.xml
Normal file
6
app/src/main/res/drawable/bg_vip_tag.xml
Normal 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>
|
||||||
12
app/src/main/res/drawable/filter_mask_bg.xml
Normal file
12
app/src/main/res/drawable/filter_mask_bg.xml
Normal 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>
|
||||||
10
app/src/main/res/drawable/ic_back.xml
Normal file
10
app/src/main/res/drawable/ic_back.xml
Normal 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>
|
||||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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
Reference in New Issue
Block a user