commit 1d2449ec44f191a3a005fbfad19935526d86c837 Author: Hermes Agent Date: Thu Oct 8 12:56:16 2026 +0800 港澳台直播 v8.3.0 源码快照(含全部历史功能) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c64162a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +local.properties +*.apk +.idea/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6a75d98 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.iptv807.tv" + compileSdk = 35 + + defaultConfig { + applicationId = "com.iptv807.tv" + minSdk = 21 + targetSdk = 35 + versionCode = 830 + versionName = "8.3.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.webkit:webkit:1.9.0") + // ExoPlayer (Media3) 播放 FLV/HLS 直播流 + implementation("androidx.media3:media3-exoplayer:1.3.1") + implementation("androidx.media3:media3-exoplayer-hls:1.3.1") + implementation("androidx.media3:media3-ui:1.3.1") + implementation("androidx.media3:media3-datasource:1.3.1") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0aa6b58 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/iptv807/tv/AiChatActivity.kt b/app/src/main/java/com/iptv807/tv/AiChatActivity.kt new file mode 100644 index 0000000..9ca5f7c --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/AiChatActivity.kt @@ -0,0 +1,201 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.KeyEvent +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +/** AI 助手 —— 超保守实现:静态布局,异常完全不崩 */ +class AiChatActivity : Activity() { + + private lateinit var scroll: ScrollView + private lateinit var chatBox: LinearLayout + private lateinit var input: EditText + private lateinit var statusText: TextView + private val handler = Handler(Looper.getMainLooper()) + private var channelsText = "" + + @SuppressLint("SetTextI18n") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { init() } catch (e: Exception) { + val r = LinearLayout(this) + r.orientation = LinearLayout.VERTICAL + val tv = TextView(this) + tv.text = "AI 加载失败(已恢复)。回按下返回重启" + tv.setTextColor(Color.RED); tv.textSize = 16f + r.addView(tv) + setContentView(r) + } + } + + @SuppressLint("SetTextI18n") + private fun init() { + val root = LinearLayout(this) + root.orientation = LinearLayout.VERTICAL + root.setBackgroundColor(Color.parseColor("#12141A")) + root.setPadding(48, 40, 48, 40) + setContentView(root) + + val title = TextView(this) + title.text = "🤖 AI 助手" + title.textSize = 24f; title.setTextColor(Color.WHITE); title.gravity = Gravity.CENTER + root.addView(title, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 20 }) + + // 检测配置 + if (!AiClient.isConfigured(this)) { + val hint = TextView(this) + hint.text = "尚未配置 AI。\n请先到 设置 → AI 配置 填写 API 地址 / Key / 模型名。" + hint.textSize = 15f; hint.setTextColor(Color.LTGRAY); hint.gravity = Gravity.CENTER + root.addView(hint, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 200 }) + val go = Button(this) + go.text = "去配置 AI"; go.textSize = 16f + go.setOnClickListener { + try { startActivity(Intent(this, AiSettingsActivity::class.java)) } catch (e: Exception) { } + } + root.addView(go, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 40 }) + return + } + + // 拼频道清单 + try { + channelsText = ChannelData.load().take(160).joinToString("\n") { c -> "${c.num}. ${c.name} [${c.group}]" } + } catch (e: Exception) { channelsText = "" } + + scroll = ScrollView(this) + scroll.setBackgroundColor(Color.parseColor("#171A20")) + chatBox = LinearLayout(this) + chatBox.orientation = LinearLayout.VERTICAL + chatBox.setPadding(24, 24, 24, 24) + scroll.addView(chatBox) + root.addView(scroll, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f + ).apply { bottomMargin = 16 }) + + // 快捷键按钮(功能入口) + val quickActions = listOf( + "📺 当前在播什么" to "@now", + "🎯 推荐值得看" to "@rec", + "📊 我的收视报告" to "@report", + ) + val row = LinearLayout(this) + row.orientation = LinearLayout.HORIZONTAL + for ((label, action) in quickActions) { + val b = Button(this) + b.text = label.take(3); b.textSize = 11f + b.setBackgroundColor(Color.parseColor("#2A2F3A")) + b.setOnClickListener { + try { ask(action, label) } catch (e: Exception) { statusText.text = "动作失败" } + } + row.addView(b, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 8 }) + } + root.addView(row, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = 16 }) + + input = EditText(this) + input.hint = "问点啥…" + input.textSize = 16f + input.setTextColor(Color.WHITE); input.setHintTextColor(Color.GRAY) + input.setBackgroundColor(Color.parseColor("#23262F")) + input.setPadding(24, 20, 24, 20) + input.setSingleLine(true) + root.addView(input, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = 16 }) + + statusText = TextView(this) + statusText.textSize = 13f; statusText.setTextColor(Color.LTGRAY); statusText.gravity = Gravity.CENTER + root.addView(statusText, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = 12 }) + + val send = Button(this) + send.text = "发送"; send.textSize = 17f + send.setOnClickListener { + try { + val q = input.text?.toString()?.trim().orEmpty() + if (q.isNotEmpty()) ask(q, null) + input.setText("") + } catch (e: Exception) { statusText.text = "发送失败" } + } + root.addView(send, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + addBubble("AI 已就绪。可以直接说话,或用上面的快捷键。", Color.parseColor("#2A3038")) + } + + private fun addBubble(text: String, bg: Int) { + try { + val v = TextView(this) + v.text = text + v.textSize = 15f; v.setTextColor(Color.WHITE) + v.setBackgroundColor(bg) + v.setPadding(20, 16, 20, 16) + chatBox.addView(v, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = 12 }) + scroll.post { scroll.fullScroll(android.view.View.FOCUS_DOWN) } + } catch (e: Exception) { } + } + + private fun ask(action: String, label: String?) { + val q = when (action) { + "@now" -> "当前时段值得看的频道有哪些节目" + "@rec" -> "推荐我现在时期值得看的3-5个频道" + "@report" -> "请基于我的收视记录给报告" + else -> action + } + addBubble(label ?: q, Color.parseColor("#1B3A5C")) + statusText.text = "思考中…" + val ctx = this + Thread { + var reply = try { + val sys = "你是Android TV IPTV应用的AI助手。频道清单(编号. 名称 [组]):\n$channelsText\n简洁回复,直接给频道编号和名字。" + AiClient.chat(ctx, sys, q, 600) + } catch (e: Exception) { "AI 调用失败: ${e.message}" } + handler.post { + try { statusText.text = "" } catch (e: Exception) { } + try { + // SWITCH:台号 切台 + if (reply.contains("SWITCH:")) { + val target = reply.substringAfter("SWITCH:").substringBefore("\n").trim() + val found = ChannelData.load().firstOrNull { ch -> ch.name.contains(target, true) } + if (found != null) { + MainActivity.playRequested = found.num + addBubble("已切到:${found.num} ${found.name}", Color.parseColor("#2A4030")) + handler.postDelayed({ try { finish() } catch (e: Exception) { } }, 800) + return@post + } + } + } catch (e: Exception) { } + addBubble(reply, Color.parseColor("#2A3038")) + } + }.start() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { + try { finish() } catch (e: Exception) { } + return true + } + return super.onKeyDown(keyCode, event) + } +} + +private fun String?.orFallback(): String = this ?: "" diff --git a/app/src/main/java/com/iptv807/tv/AiClient.kt b/app/src/main/java/com/iptv807/tv/AiClient.kt new file mode 100644 index 0000000..da8faa1 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/AiClient.kt @@ -0,0 +1,62 @@ +package com.iptv807.tv + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets + +/** + * 通用 AI 客户端 —— 兼容 OpenAI /api/chat/completions 格式 + * 用户在设置里填: BASE_URL / API_KEY / MODEL(任何一个 OpenAI 兼容服务都可用, + * 如 new-api 一站式、DeepSeek、通义、Kimi、OpenAI、本地Ollama等) + */ +object AiClient { + private const val PREF = "iptv_ai" + + fun baseUrl(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("base_url", "https://ai.88531.cn/v1") ?: "https://ai.88531.cn/v1" + fun apiKey(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("api_key", "") ?: "" + fun model(ctx: Context) = ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).getString("model", "deepseek-v4.1-flash") ?: "deepseek-v4.1-flash" + + fun isConfigured(ctx: Context): Boolean = + baseUrl(ctx).isNotEmpty() && apiKey(ctx).isNotEmpty() && model(ctx).isNotEmpty() + + fun save(ctx: Context, baseUrl: String, apiKey: String, model: String) { + ctx.getSharedPreferences(PREF, Context.MODE_PRIVATE).edit() + .putString("base_url", baseUrl.trim().removeSuffix("/")) + .putString("api_key", apiKey.trim()) + .putString("model", model.trim()).apply() + } + + /** chat 请求(同步,调用方自行放子线程)返回回复文本,失败抛异常 */ + fun chat(ctx: Context, systemPrompt: String, userPrompt: String, maxTokens: Int = 500): String { + val url = URL(baseUrl(ctx) + "/chat/completions") + val body = JSONObject().apply { + put("model", model(ctx)) + put("max_tokens", maxTokens) + put("temperature", 0.6) + put("messages", JSONArray() + .put(JSONObject().put("role", "system").put("content", systemPrompt)) + .put(JSONObject().put("role", "user").put("content", userPrompt))) + } + val conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 10000 + readTimeout = 60000 + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Authorization", "Bearer ${apiKey(ctx)}") + doOutput = true + outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) } + } + val code = conn.responseCode + val text = (if (code in 200..299) conn.inputStream else conn.errorStream) + ?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: "" + conn.disconnect() + if (code !in 200..299) throw RuntimeException("HTTP $code: ${text.take(180)}") + val j = JSONObject(text) + return j.optJSONArray("choices")?.optJSONObject(0)?.optJSONObject("message")?.optString("content") ?: "" + } +} diff --git a/app/src/main/java/com/iptv807/tv/AiSettingsActivity.kt b/app/src/main/java/com/iptv807/tv/AiSettingsActivity.kt new file mode 100644 index 0000000..3ea1deb --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/AiSettingsActivity.kt @@ -0,0 +1,99 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.graphics.Color +import android.os.Bundle +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView + +/** 用户自定义 AI 服务配置(OpenAI兼容格式) */ +class AiSettingsActivity : Activity() { + + private lateinit var baseEdit: EditText + private lateinit var keyEdit: EditText + private lateinit var modelEdit: EditText + private lateinit var statusText: TextView + + @SuppressLint("SetTextI18n") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#12141A")) + setPadding(80, 56, 80, 56) + } + setContentView(root) + + root.addView(TextView(this).apply { + text = "AI 功能配置" + textSize = 26f; setTextColor(Color.WHITE); gravity = android.view.Gravity.CENTER + setPadding(0, 0, 0, 12) + }) + root.addView(TextView(this).apply { + text = "兼容 OpenAI 格式。支持:DeepSeek、通义、Kimi、OpenAI、本地Ollama、NewAPI 中转等。填你的 key 即可。" + textSize = 13f; setTextColor(Color.LTGRAY); gravity = android.view.Gravity.CENTER + setPadding(0, 0, 0, 32) + }) + + fun mkEdit(hint: String, v: String = "") = EditText(this).apply { + this.hint = hint + setText(v) + setSingleLine(true) + textSize = 15f + setTextColor(Color.WHITE); setHintTextColor(Color.GRAY) + setBackgroundColor(Color.parseColor("#23262F")) + setPadding(24, 20, 24, 20) + } + + baseEdit = mkEdit("https://ai.88531.cn/v1(可改自己的)", AiClient.baseUrl(this)) + keyEdit = mkEdit("API Key(sk-…)", AiClient.apiKey(this)) + modelEdit = mkEdit("deepseek-v4.1-flash(可改)", AiClient.model(this)) + listOf(baseEdit, keyEdit, modelEdit).forEach { + root.addView(it, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = 24 }) + } + + val saveBtn = Button(this).apply { text = "保存"; textSize = 17f } + val testBtn = Button(this).apply { text = "测试连接"; textSize = 17f } + val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL } + row.addView(saveBtn, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 16 }) + row.addView(testBtn, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + root.addView(row) + + statusText = TextView(this).apply { + textSize = 14f; setTextColor(Color.LTGRAY); gravity = android.view.Gravity.CENTER + setPadding(0, 32, 0, 0) + text = "AI 功能:语音搜台 · 节目解读 · 故障诊断 · 智能推荐 · 找片 · 收视报告" + } + root.addView(statusText) + + saveBtn.setOnClickListener { + if (baseEdit.text.trim().isEmpty() || keyEdit.text.trim().isEmpty() || modelEdit.text.trim().isEmpty()) { + statusText.text = "三项都要填"; return@setOnClickListener + } + AiClient.save(this, baseEdit.text.toString(), keyEdit.text.toString(), modelEdit.text.toString()) + statusText.setTextColor(Color.parseColor("#4CAF50")) + statusText.text = "✓ 已保存" + } + testBtn.setOnClickListener { + if (!AiClient.isConfigured(this)) { + statusText.setTextColor(Color.RED); statusText.text = "先填写并保存三项"; return@setOnClickListener + } + statusText.setTextColor(Color.LTGRAY) + statusText.text = "测试中…" + Thread { + try { + val r = AiClient.chat(this, "你是测试助手", "回复:OK") + runOnUiThread { statusText.setTextColor(Color.parseColor("#4CAF50")); statusText.text = "✓ 连通:$r" } + } catch (e: Exception) { + runOnUiThread { statusText.setTextColor(Color.RED); statusText.text = "✗ ${e.message}" } + } + }.start() + } + } +} diff --git a/app/src/main/java/com/iptv807/tv/BootReceiver.kt b/app/src/main/java/com/iptv807/tv/BootReceiver.kt new file mode 100644 index 0000000..758e3ed --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/BootReceiver.kt @@ -0,0 +1,16 @@ +package com.iptv807.tv + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** 开机自动启动(用户在设置里打开开关才启用) */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val i = Intent(context, LoginActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(i) + } + } +} diff --git a/app/src/main/java/com/iptv807/tv/CardAuthApi.kt b/app/src/main/java/com/iptv807/tv/CardAuthApi.kt new file mode 100644 index 0000000..ced805c --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/CardAuthApi.kt @@ -0,0 +1,38 @@ +package com.iptv807.tv + +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets + +/** CardAuth 公开API的轻量封装 */ +object CardAuthApi { + private const val BASE = "http://app.88531.cn:8881" + private const val API_KEY = "ak_563f4768d5d411d381e58bc3f76ba447" + + fun get(path: String): JSONObject = request("GET", path, null) + fun post(path: String, body: JSONObject): JSONObject = request("POST", path, body.toString()) + + private fun request(method: String, path: String, body: String?): JSONObject { + val conn = (URL(BASE + path).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 8000; readTimeout = 10000 + setRequestProperty("Content-Type", "application/json") + setRequestProperty("X-API-Key", API_KEY) + if (body != null && method == "POST") { + doOutput = true + outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) } + } + } + val code = conn.responseCode + val text = (if (code in 200..299) conn.inputStream else conn.errorStream) + ?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: "" + conn.disconnect() + if (text.trimStart().startsWith("<")) { + throw RuntimeException("服务端返回了HTML(路径错误/网关拦截),HTTP $code") + } + return JSONObject(text) + } +} diff --git a/app/src/main/java/com/iptv807/tv/ChannelData.kt b/app/src/main/java/com/iptv807/tv/ChannelData.kt new file mode 100644 index 0000000..dd8b070 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/ChannelData.kt @@ -0,0 +1,75 @@ +package com.iptv807.tv + +/** + * 港澳台专项清单(55台)—— 3大子分组(香港 / 澳门 / 台湾) + * 存 tid+id,播放Url每次实时解密(token 每次都换) + */ +object ChannelData { + data class Channel(val num: Int, val name: String, val group: String, val tid: String, val id: String) + data class Group(val name: String, val startNum: Int, val count: Int) + + fun load(): List = listOf( + Channel(1, "HOY TV", "🌏 香港", "tv", "43"), + Channel(2, "HOY国际财经台", "🌏 香港", "tv", "41"), + Channel(3, "HOY资讯台", "🌏 香港", "tv", "42"), + Channel(4, "NOW新闻台", "🌏 香港", "tv", "36"), + Channel(5, "NOW财经台", "🌏 香港", "tv", "37"), + Channel(6, "TVB Plus", "🌏 香港", "tv", "26"), + Channel(7, "TVB无线新闻台", "🌏 香港", "tv", "25"), + Channel(8, "TVB明珠台", "🌏 香港", "tv", "27"), + Channel(9, "TVB翡翠台", "🌏 香港", "tv", "24"), + Channel(10, "ViuTV", "🌏 香港", "tv", "38"), + Channel(11, "ViuTV6", "🌏 香港", "tv", "39"), + Channel(12, "凤凰卫视中文台", "🌏 香港", "tv", "20"), + Channel(13, "凤凰卫视资讯台", "🌏 香港", "tv", "21"), + Channel(14, "凤凰卫视香港台", "🌏 香港", "tv", "22"), + Channel(15, "港台电视31", "🌏 香港", "tv", "45"), + Channel(16, "港台电视32", "🌏 香港", "gt", "27"), + Channel(17, "香港卫视HKS", "🌏 香港", "gt", "30"), + Channel(18, "香港有线18台", "🌏 香港", "gt", "12"), + Channel(19, "香港有线18台(备)", "🌏 香港", "gt", "13"), + Channel(20, "香港有线新闻台", "🌏 香港", "tv", "29"), + Channel(21, "澳視澳門", "🌏 澳门", "gt", "38"), + Channel(22, "澳视综艺", "🌏 澳门", "gt", "40"), + Channel(23, "澳视资讯", "🌏 澳门", "gt", "37"), + Channel(24, "澳門Macau", "🌏 澳门", "gt", "39"), + Channel(25, "澳門卫视", "🌏 澳门", "gt", "36"), + Channel(26, "澳门莲花", "🌏 澳门", "gt", "35"), + Channel(27, "DAZN 1", "🌏 台湾", "tv", "80"), + Channel(28, "DAZN 2", "🌏 台湾", "tv", "81"), + Channel(29, "ELEVEN 1(DAZN 1)", "🌏 台湾", "ty", "24"), + Channel(30, "ELEVEN 2(DAZN 2)", "🌏 台湾", "ty", "25"), + Channel(31, "TVBS HD", "🌏 台湾", "tv", "65"), + Channel(32, "TVBS新闻台", "🌏 台湾", "tv", "63"), + Channel(33, "三立新闻台", "🌏 台湾", "tv", "54"), + Channel(34, "东森新闻台", "🌏 台湾", "tv", "50"), + Channel(35, "东森财经新闻台", "🌏 台湾", "gt", "45"), + Channel(36, "东森超视", "🌏 台湾", "tv", "98"), + Channel(37, "中天新闻台", "🌏 台湾", "tv", "49"), + Channel(38, "中视HD", "🌏 台湾", "tv", "68"), + Channel(39, "中视新闻台", "🌏 台湾", "tv", "52"), + Channel(40, "华视HD", "🌏 台湾", "tv", "71"), + Channel(41, "华视新闻台", "🌏 台湾", "tv", "57"), + Channel(42, "台视HD", "🌏 台湾", "tv", "69"), + Channel(43, "台视新闻台", "🌏 台湾", "tv", "53"), + Channel(44, "寰宇新闻台", "🌏 台湾", "tv", "59"), + Channel(45, "寰宇新闻台湾台", "🌏 台湾", "tv", "60"), + Channel(46, "民视HD", "🌏 台湾", "tv", "70"), + Channel(47, "民视新闻台", "🌏 台湾", "tv", "56"), + Channel(48, "爱尔达体育1台", "🌏 台湾", "tv", "83"), + Channel(49, "爱尔达体育2台", "🌏 台湾", "tv", "84"), + Channel(50, "緯來體育台", "🌏 台湾", "tv", "77"), + Channel(51, "纬来日本台", "🌏 台湾", "tv", "78"), + Channel(52, "纬来育乐台", "🌏 台湾", "tv", "79"), + Channel(53, "镜电视新闻台", "🌏 台湾", "tv", "58"), + Channel(54, "非凡新闻", "🌏 台湾", "tv", "62"), + Channel(55, "华丽翡翠台", "🌏 其他", "tv", "28") + ) + + fun groups(): List = listOf( + Group("🌏 香港", 1, 20), + Group("🌏 澳门", 21, 6), + Group("🌏 台湾", 27, 28), + Group("🌏 其他", 55, 1) + ) +} diff --git a/app/src/main/java/com/iptv807/tv/HistoryRecorder.kt b/app/src/main/java/com/iptv807/tv/HistoryRecorder.kt new file mode 100644 index 0000000..8cb93a9 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/HistoryRecorder.kt @@ -0,0 +1,36 @@ +package com.iptv807.tv + +import android.content.Context +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** 收视历史(供 AI 推荐/报告用) */ +object HistoryRecorder { + private fun prefs(ctx: Context) = ctx.getSharedPreferences("iptv_history", Context.MODE_PRIVATE) + private val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.CHINA) + + fun log(ctx: Context, chanNum: Int, name: String) { + val now = fmt.format(Date()) + val p = prefs(ctx) + var count = p.getInt("count_${chanNum}", 0) + count++ + p.edit().putInt("count_${chanNum}", count) + .putString("last_" + chanNum, "$now $name").apply() + // 只保留最近 90 days(清理老数据) + if (p.all.size > 200) { + val sorted = p.all.keys.sorted() + p.edit().remove(sorted.take(50).toString()).apply() + } + } + + fun topChannels(ctx: Context, n: Int = 5): List> { + val p = prefs(ctx) + return p.all.entries + .filter { it.key.startsWith("count_") } + .sortedByDescending { it.value as Int } + .take(n) + .map { it.key.removePrefix("count_").toInt() } + .mapNotNull { num -> ChannelData.load().find { it.num == num }?.let { num to it.name } } + } +} diff --git a/app/src/main/java/com/iptv807/tv/IptvApp.kt b/app/src/main/java/com/iptv807/tv/IptvApp.kt new file mode 100644 index 0000000..4455ba9 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/IptvApp.kt @@ -0,0 +1,19 @@ +package com.iptv807.tv + +import android.app.Application + +/** 应用级 crash 防护:把异常写标记后照常崩溃(不吞什),下次启动 reset license 缓存 */ +class IptvApp : Application() { + override fun onCreate() { + super.onCreate() + val default = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { t, e -> + try { + getSharedPreferences("iptv_crash", MODE_PRIVATE) + .edit().putBoolean("crashed", true) + .putString("msg", "${e.javaClass.simpleName}: ${e.message?.take(200)}").commit() + } catch (ex: Exception) { } + default?.uncaughtException(t, e) + } + } +} diff --git a/app/src/main/java/com/iptv807/tv/LicenseClient.kt b/app/src/main/java/com/iptv807/tv/LicenseClient.kt new file mode 100644 index 0000000..a46cbf9 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/LicenseClient.kt @@ -0,0 +1,116 @@ +package com.iptv807.tv + +import android.content.Context +import android.os.Build +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * CardAuth 授权客户端(app.88531.cn:8881) + * + * 协议(已实测): + * POST /api/public/verify + * Header: X-API-Key: ak_...(项目API密钥) + * Body: {"card_key":"...","machine_id":"TV-XXXX","device_info":"AndroidTV"} + * 未用卡 → 自动激活绑定;已绑 → 校验machine_id一致 + 未过期 + * 响应 data: {valid, type, duration_days, expire_time, is_permanent, remaining_days} + * 免费用户:无卡/无效卡 → 每天最多1小时(本地计时 + 服务器日期同步) + */ +object LicenseClient { + private const val BASE = "http://app.88531.cn:8881" + private const val API_KEY = "ak_563f4768d5d411d381e58bc3f76ba447" + + data class Result( + val ok: Boolean, // 通过授权(VIP) + val message: String, + val expireTime: String? = null, + val isPermanent: Boolean = false, + val remainingDays: Int? = null, + val trial: Boolean = false // 走免费1小时 + ) + + /** 卡密验证/激活 */ + fun verify(cardKey: String, machineId: String): Result { + return try { + val body = JSONObject().apply { + put("card_key", cardKey) + put("machine_id", machineId) + put("device_info", "AndroidTV ${Build.MODEL}") + } + val r = post("/api/public/verify", body) + val data = r.optJSONObject("data") ?: JSONObject() + when { + r.optInt("code") == 200 && data.optBoolean("valid") -> + Result(true, data.optString("message", "授权有效"), + data.optString("expire_time", ""), data.optBoolean("is_permanent"), + if (data.has("remaining_days") && !data.isNull("remaining_days")) data.optInt("remaining_days") else null) + else -> Result(false, data.optString("message", r.optString("message", "授权无效")).ifEmpty { "卡密无效" }) + } + } catch (e: Exception) { + Result(false, "网络错误: ${e.message ?: e.javaClass.simpleName}") + } + } + + private fun post(path: String, body: JSONObject): JSONObject { + val url = URL(BASE + path) + val conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 8000 + readTimeout = 10000 + setRequestProperty("Content-Type", "application/json") + setRequestProperty("X-API-Key", API_KEY) + doOutput = true + outputStream.use { it.write(body.toString().toByteArray(StandardCharsets.UTF_8)) } + } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val text = stream?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: "" + conn.disconnect() + return JSONObject(text) + } + + /** 稳定机器码(安装期持久化+硬件融合) */ + fun machineCode(ctx: Context): String { + val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE) + prefs.getString("machine_code", null)?.let { return it } + val raw = listOf(Build.BOARD, Build.BRAND, Build.DEVICE, Build.MODEL, Build.PRODUCT).joinToString("|") + val md = MessageDigest.getInstance("SHA-256") + val code = "TV-" + md.digest(raw.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02X".format(it) }.take(12) + prefs.edit().putString("machine_code", code).apply() + return code + } + + // ===== 免费时长控制(每天1小时)===== + const val FREE_DAILY_MS = 60 * 60 * 1000L + + /** 今天已用免费毫秒数 */ + fun freeUsedTodayMs(ctx: Context): Long { + val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE) + val today = todayKey() + if (prefs.getString("free_date", "") != today) return 0 + return prefs.getLong("free_used_ms", 0) + } + + fun addFreeUsedMs(ctx: Context, delta: Long) { + val prefs = ctx.getSharedPreferences("iptv_license", Context.MODE_PRIVATE) + val today = todayKey() + val used = if (prefs.getString("free_date", "") == today) prefs.getLong("free_used_ms", 0) else 0 + prefs.edit().putString("free_date", today).putLong("free_used_ms", used + delta).apply() + } + + fun freeRemainingMs(ctx: Context): Long = + (FREE_DAILY_MS - freeUsedTodayMs(ctx)).coerceAtLeast(0) + + /** 日期键北京时间 */ + private fun todayKey(): String { + val f = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.CHINA) + f.timeZone = java.util.TimeZone.getTimeZone("GMT+8") + return f.format(java.util.Date()) + } +} diff --git a/app/src/main/java/com/iptv807/tv/LoginActivity.kt b/app/src/main/java/com/iptv807/tv/LoginActivity.kt new file mode 100644 index 0000000..9ceea3b --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/LoginActivity.kt @@ -0,0 +1,143 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.KeyEvent +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast + +/** + * CardAuth 授权登录页 —— 应用第一屏: + * 有效卡本地缓存 → 直接进播放;否则显示卡密输入 + * CardAuth 无用户体系:一卡一机绑定,验证即在服务端注册到期时间 + */ +class LoginActivity : Activity() { + + private lateinit var cardEdit: EditText + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + // crashGuard: 若上次有崩溃标记,先清before检查 + getSharedPreferences("iptv_crash", MODE_PRIVATE).let { + if (it.getBoolean("crashed", false)) { + it.edit().putBoolean("crashed", false).apply() + // 清理license cache以防被污染 + getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply() + } + } + } catch (e: Exception) { } + + val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE) + val machine = LicenseClient.machineCode(this) + + // 本地已授权缓存 → 快速放行(在线重验在主界面做,避免卡启动) + if (prefs.getBoolean("is_vip", false)) { + startMain(); return + } + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = android.view.Gravity.CENTER + setBackgroundColor(Color.parseColor("#12141A")) + setPadding(96, 64, 96, 64) + } + setContentView(root) + + root.addView(TextView(this).apply { + text = "港澳台直播" + textSize = 30f; setTextColor(Color.WHITE) + gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 16) + }) + root.addView(TextView(this).apply { + text = "免费用户每天可看 1 小时 · 输入卡密解锁全天观看" + textSize = 15f; setTextColor(Color.LTGRAY) + gravity = android.view.Gravity.CENTER; setPadding(0, 0, 0, 48) + }) + + cardEdit = EditText(this).apply { + hint = "卡密(如 8914-XXXX-XXXX)" + setSingleLine(true) + textSize = 18f + setTextColor(Color.WHITE); setHintTextColor(Color.GRAY) + setBackgroundColor(Color.parseColor("#23262F")) + setPadding(24, 24, 24, 24) + imeOptions = EditorInfo.IME_ACTION_DONE + } + root.addView(cardEdit, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 32 }) + + val btn = Button(this).apply { + text = "激活 / 验证"; textSize = 19f + setOnClickListener { doVerify() } + } + root.addView(btn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + statusText = TextView(this).apply { + textSize = 14f; setTextColor(Color.LTGRAY) + gravity = android.view.Gravity.CENTER; setPadding(0, 40, 0, 0) + text = "已有卡密可直接激活;或购买套餐自动绑定本机" + } + root.addView(statusText) + + val shopBtn = Button(this).apply { + text = "购买套餐(支付宝/微信)"; textSize = 15f + setBackgroundColor(Color.parseColor("#1B6EF3")) + setOnClickListener { startActivity(android.content.Intent(this@LoginActivity, ShopActivity::class.java)) } + } + root.addView(shopBtn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 24 }) + + val freeBtn = Button(this).apply { + text = "先看一会(免费)"; textSize = 14f + setBackgroundColor(Color.parseColor("#2A3344")) + setOnClickListener { + getSharedPreferences("iptv_license", MODE_PRIVATE).edit().putBoolean("is_vip", false).apply() + startMain() + } + } + root.addView(freeBtn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { topMargin = 24 }) + + cardEdit.setOnEditorActionListener { _,_,_ -> doVerify(); true } + } + + private fun doVerify() { + val card = cardEdit.text.toString().trim() + if (card.isEmpty()) { statusText.text = "请输入卡密"; return } + statusText.text = "验证中…" + val machine = LicenseClient.machineCode(this) + Thread { + val r = LicenseClient.verify(card, machine) + runOnUiThread { + if (r.ok) { + getSharedPreferences("iptv_license", MODE_PRIVATE).edit() + .putBoolean("is_vip", true) + .putString("card_key", card) + .putString("vip_expire", r.expireTime) + .putBoolean("is_permanent", r.isPermanent) + .apply() + Toast.makeText(this, "✓ ${r.message}${if (!r.isPermanent && r.expireTime != null) "\n到期: ${r.expireTime}" else ""}", Toast.LENGTH_LONG).show() + startMain() + } else { + statusText.text = r.message + } + } + }.start() + } + + private fun startMain() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } +} diff --git a/app/src/main/java/com/iptv807/tv/MainActivity.kt b/app/src/main/java/com/iptv807/tv/MainActivity.kt new file mode 100644 index 0000000..fbd9d47 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/MainActivity.kt @@ -0,0 +1,742 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.CountDownTimer +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.PlayerView + +/** + * 港澳台直播 v7.0 —— 纯原生播放(无网页兜底) + * + * · App 本地解密获取新鲜播放源 → ExoPlayer 直接播放(带 Referer/302) + * (纯原生播放,无网页兜底) + */ +class MainActivity : Activity() { + + private val channels: List = ChannelData.load() + private val groups: List = ChannelData.groups() + private val favGroup = ChannelData.Group("❤ 收藏", 0, 0) + + private var player: ExoPlayer? = null + private lateinit var playerView: PlayerView + + private lateinit var statusText: TextView + private lateinit var trialText: TextView + private lateinit var digitText: TextView + private lateinit var loadingText: TextView + private lateinit var loadingMask: FrameLayout + private lateinit var root: FrameLayout + private var panelLayout: LinearLayout? = null + private var groupList: ListView? = null + private var channelList: ListView? = null + private var curGroupPos = 0 + private var currentChannel = -1 + private var currentLine = 0 + private var mode = MODE_NATIVE + private val handler = Handler(Looper.getMainLooper()) + private val hidePanelTask = Runnable { hidePanel() } + private val uiTick = Runnable { onUiTick() } + private var loadStart = 0L + private val loadTick = object : Runnable { + override fun run() { + if (loadingMask.visibility != View.VISIBLE) return + val s = ((System.currentTimeMillis() - loadStart) / 1000 + 1).toInt() + loadingText.text = "正在加载 $s 秒,精彩继续…" + handler.postDelayed(this, 1000) + } + } + + private var digitBuf = "" + private val digitTask = object : CountDownTimer(2000, 2000) { + override fun onTick(m: Long) {} + override fun onFinish() { if (digitBuf.isNotEmpty()) { commitDigit(); digitBuf = "" } } + } + + private var isVip = false + private var lastPlayError = "" + private var lastSelfTest = "" + private var failureReport = "" + private var wasStopped = false + private var resumeBtn: TextView? = null + // 连续滑动两次退出 + private var lastSwipeTs = 0L + private var downX = 0f + private var downY = 0f + private var downT = 0L + private var exitHint: TextView? = null + + companion object { + const val MODE_NATIVE = 0 + @JvmStatic var playRequested: Int = -1 + } + + @SuppressLint("SetJavaScriptEnabled", "SetTextI18n", "ClickableViewAccessibility") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { init(savedInstanceState) } catch (e: Exception) { + // 保守恢复:显示错误不闪退 + val r = FrameLayout(this) + r.setBackgroundColor(Color.BLACK) + val tv = TextView(this) + tv.text = "启动异常已恢复 (${e.javaClass.simpleName})\n请按返回键重启" + tv.setTextColor(Color.WHITE); tv.textSize = 18f; tv.gravity = Gravity.CENTER + r.addView(tv) + setContentView(r) + } + } + + @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") + private fun init(savedInstanceState: Bundle?) { + hideSystemUi() + root = FrameLayout(this) + setContentView(root) + + // 1) 原生播放层 + playerView = PlayerView(this).apply { useController = false } + root.addView(playerView, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + + // 3) 左上状态 + statusText = TextView(this).apply { + textSize = 18f; setTextColor(Color.WHITE) + setShadowLayer(4f, 2f, 2f, Color.BLACK) + setPadding(32, 24, 32, 24) + } + root.addView(statusText, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + + // 4) 右上:免费时长 + 模式 + val rightCol = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } + trialText = TextView(this).apply { + textSize = 14f; setTextColor(Color.WHITE) + setBackgroundColor(0x88000000u.toInt()) + setPadding(20, 10, 20, 10) + } + rightCol.addView(trialText) + val rcLP = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END) + root.addView(rightCol, rcLP) + + // 5) 数字键大字 + digitText = TextView(this).apply { + textSize = 56f; setTextColor(Color.WHITE) + setShadowLayer(6f, 3f, 3f, Color.BLACK) + setPadding(24, 64, 48, 24) + visibility = View.GONE + } + root.addView(digitText, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END)) + + // 6) 加载罩 + loadingMask = FrameLayout(this).apply { setBackgroundColor(0xB2000000u.toInt()); visibility = View.GONE } + loadingText = TextView(this).apply { + textSize = 28f; setTextColor(Color.WHITE); gravity = Gravity.CENTER + setShadowLayer(6f, 3f, 3f, Color.BLACK) + } + loadingMask.addView(loadingText, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + root.addView(loadingMask, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + + // 8) 「继续播放」按钮(从桌面回来画面卡住时点它重连) + resumeBtn = TextView(this).apply { + text = "▶ 继续播放" + textSize = 22f + setTextColor(Color.WHITE) + setBackgroundColor(0xE61B6EF3u.toInt()) + setPadding(48, 24, 48, 24) + gravity = Gravity.CENTER + isFocusable = true + visibility = View.GONE + setOnClickListener { resumePlayback() } + } + root.addView(resumeBtn, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) + + // 7) ExoPlayer 初始化 + val loadControl = androidx.media3.exoplayer.DefaultLoadControl.Builder() + .setBufferDurationsMs(1500, 12000, 500, 1000) + .build() + player = ExoPlayer.Builder(this).setLoadControl(loadControl).setMediaSourceFactory( + DefaultMediaSourceFactory( + DefaultDataSource.Factory(this, + DefaultHttpDataSource.Factory() + .setUserAgent("Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36") + .setDefaultRequestProperties(mapOf( + "Referer" to "https://m.iptv807.com/", + "Origin" to "https://m.iptv807.com" + )) + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(8000) + .setReadTimeoutMs(12000) + ) + )).build() + playerView.player = player + + player?.addListener(object : Player.Listener { + override fun onPlayerError(err: androidx.media3.common.PlaybackException) { + lastPlayError = "播放错误 code=${err.errorCode}: ${err.message?.take(60) ?: ""}" + autoSwitchLine() + } + override fun onPlaybackStateChanged(state: Int) { + when (state) { + Player.STATE_READY -> { setMode(MODE_NATIVE); hideLoading() } + Player.STATE_BUFFERING -> { /* 缓冲中,保持计时继续 */ } + } + } + override fun onIsPlayingChanged(p: Boolean) { + if (p) { hideLoading(); resumeBtn?.visibility = View.GONE } + } + }) + + // 触屏/遥控 OK 弹面板 + playerView.setOnClickListener { if (!isPanelVisible()) showPanel() else hidePanel() } + + SourceResolver.init(this) + // 播放期间保持屏幕常亮 + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + checkLicense() + if (savedInstanceState == null && channels.isNotEmpty()) { + val defIdx = channels.indexOfFirst { it.name.contains("TVB翡翠台") }.let { if (it >= 0) it else 0 } + playChannel(defIdx) + // 启动后台预取默认频道所在分组,之后切台秒开 + val g = channels.getOrNull(defIdx)?.group + val gi = ChannelData.groups().indexOfFirst { it.name == g } + if (gi >= 0) handler.postDelayed({ prefetchGroup(gi) }, 2500) + } + } + + private fun setMode(m: Int) { mode = m } + + /** 启动自检:解析源 + 实测第一线路连通性,结果在状态栏显示 10 秒 */ + private fun runSelfTest(idx: Int) { + val ch = channels.getOrNull(idx) ?: return + Thread { + val t0 = System.currentTimeMillis() + val urls = try { SourceResolver.resolve(ch.tid, ch.id) } catch (e: Exception) { null } + val resolveMs = System.currentTimeMillis() - t0 + val info = StringBuilder() + if (urls.isNullOrEmpty()) { + info.append("自检: 解析失败(${SourceResolver.lastError})") + } else { + info.append("自检: 解析${urls.size}线/${resolveMs}ms") + // 实测第一条能否连上 + info.append(" | ").append(SourceResolver.verifyChain(ch.tid, ch.id)) + } + runOnUiThread { + statusText.text = info.toString() + handler.postDelayed({ + // 10 秒后恢复正常显示 + val c = channels.getOrNull(currentChannel) + if (c != null) statusText.text = "▶ ${c.num} ${c.name}" + }, 10000) + } + }.start() + } + + private fun freeRemainingMs(): Long = LicenseClient.freeRemainingMs(this) + private fun checkLicense() { + val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE) + val card = prefs.getString("card_key", "") ?: "" + Thread { + val r = if (card.isNotEmpty()) LicenseClient.verify(card, LicenseClient.machineCode(this)) else LicenseClient.Result(false, "免费") + runOnUiThread { + isVip = r.ok + prefs.edit().putBoolean("is_vip", r.ok).apply() + if (r.ok) { trialText.visibility = View.GONE; trialText.text = "VIP" } else enterFreeMode() + } + }.start() + } + + private fun enterFreeMode() { + trialText.visibility = View.VISIBLE + handler.removeCallbacks(uiTick) + handler.post(uiTick) + } + + private fun onUiTick() { + if (isVip) return + // 每秒累计一次免费用量,这样右上角倒计时才会真正走动 + if (player?.isPlaying == true) LicenseClient.addFreeUsedMs(this, 1000) + val r = freeRemainingMs() + trialText.visibility = View.VISIBLE + trialText.text = if (r <= 0) "⏳ 今日免費已用完" + else "⏳ 今日免費 ${r / 60000}分${((r % 60000) / 1000).toString().padStart(2, '0')}秒" + handler.postDelayed(uiTick, 1000) + } + + private fun showTrialExpired() { + player?.pause() + trialText.visibility = View.GONE + statusText.text = "今日免费时长已用完" + startActivity(Intent(this, TrialExpiredActivity::class.java)) + finish() + } + + private fun isPanelVisible(): Boolean = panelLayout?.visibility == View.VISIBLE + + private fun panelGroups(): List = + if (favSet().isNotEmpty()) listOf(favGroup) + groups else groups + + private fun favSet(): MutableSet = + getSharedPreferences("iptv_fav", MODE_PRIVATE).getStringSet("favs", HashSet())!!.toMutableSet() + + private fun toggleFav(ch: ChannelData.Channel) { + val s = favSet() + val n = ch.num.toString() + if (n in s) s.remove(n) else s.add(n) + getSharedPreferences("iptv_fav", MODE_PRIVATE).edit().putStringSet("favs", s).apply() + } + + private fun channelsOfGroup(gpos: Int): List { + val g = panelGroups()[gpos] + return if (g.name == favGroup.name) { + favSet().mapNotNull { n -> channels.find { it.num == n.toInt() } }.sortedBy { it.num } + } else channels.filter { it.group == g.name } + } + + @SuppressLint("ClickableViewAccessibility") + private fun showPanel() { + if (panelLayout == null) { + panelLayout = buildPanel() + root.addView(panelLayout, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + panelLayout?.visibility = View.VISIBLE + val gpos = panelGroups().indexOfFirst { g -> + val num = channels.getOrNull(currentChannel)?.num + if (g.name == favGroup.name) num?.toString() in favSet() + else g.name == channels.getOrNull(currentChannel)?.group + } + if (gpos >= 0) { curGroupPos = gpos; groupList?.setSelection(gpos + 1); updateChannelListOfGroup(gpos) } + resetHideTimer() + } + + @SuppressLint("ClickableViewAccessibility") + private fun buildPanel(): LinearLayout = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + setBackgroundColor(0xE8141418u.toInt()) + setOnClickListener { hidePanel() } + + groupList = ListView(this@MainActivity).apply { + setBackgroundColor(Color.TRANSPARENT) + adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_list_item_1, + listOf("⚙ 设置") + panelGroups().map { "${it.name} (${it.count})" }) + onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ -> + if (pos == 0) { + startActivity(Intent(this@MainActivity, SettingsActivity::class.java)) + hidePanel(); return@OnItemClickListener + } + curGroupPos = pos - 1 + groupList?.setSelection(pos) + updateChannelListOfGroup(curGroupPos) + } + setPadding(8, 24, 8, 24) + setOnTouchListener { v, ev -> + if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) resetHideTimer() + v.onTouchEvent(ev) + } + } + addView(groupList, LinearLayout.LayoutParams( + (resources.displayMetrics.widthPixels * 0.2f).toInt(), ViewGroup.LayoutParams.MATCH_PARENT)) + + channelList = ListView(this@MainActivity).apply { + setBackgroundColor(Color.TRANSPARENT) + onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ -> + val list = channelsOfGroup(curGroupPos) + if (pos < list.size) { playChannel(channels.indexOf(list[pos])); hidePanel() } + } + setPadding(8, 24, 8, 24) + setOnTouchListener { v, ev -> + if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) resetHideTimer() + v.onTouchEvent(ev) + } + setOnScrollListener(object : AbsListView.OnScrollListener { + override fun onScrollStateChanged(v: AbsListView, s: Int) { resetHideTimer() } + override fun onScroll(v: AbsListView, a: Int, b: Int, c: Int) { resetHideTimer() } + }) + } + addView(channelList, LinearLayout.LayoutParams( + (resources.displayMetrics.widthPixels * 0.38f).toInt(), ViewGroup.LayoutParams.MATCH_PARENT)) + } + + private fun updateChannelListOfGroup(pos: Int) { + prefetchGroup(pos) + val list = channelsOfGroup(pos) + channelList?.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, + list.map { "%3d %s".format(it.num, it.name) }) + } + + private fun hidePanel() { handler.removeCallbacks(hidePanelTask); panelLayout?.visibility = View.GONE } + + /** 后台预取某分组所有频道的源,让切台变秒切(每次运行只做一轮) */ + private var prefetchedGroups = HashSet() + private fun prefetchGroup(gpos: Int) { + if (prefetchedGroups.contains(gpos)) return + prefetchedGroups.add(gpos) + val list = channelsOfGroup(gpos) + Thread { + for (ch in list) { + try { SourceResolver.resolveLines(ch.tid, ch.id) } catch (e: Exception) { } + try { Thread.sleep(250) } catch (ie: InterruptedException) { } + } + }.start() + } + private fun resetHideTimer() { + handler.removeCallbacks(hidePanelTask) + handler.postDelayed(hidePanelTask, 5000) + } + + private fun hideLoading() { loadingMask.visibility = View.GONE; handler.removeCallbacks(loadTick) } + + /** 开始一次新的换台计时(只在切台时调用) */ + private fun beginLoading() { + loadStart = System.currentTimeMillis() + loadingText.text = "正在加载 1 秒,精彩继续…" + loadingMask.visibility = View.VISIBLE + handler.removeCallbacks(loadTick) + handler.postDelayed(loadTick, 1000) + } + + private fun playChannel(idx: Int, line: Int = 0) { + if (idx >= channels.size) return + val isSameChannel = (idx == currentChannel) + currentChannel = idx; currentLine = line + val ch = channels[idx] + if (!isSameChannel || loadingMask.visibility != View.VISIBLE) { + beginLoading() // 只有换台才重置计时;同台换线路时继续计时 + } + Thread { + val urls = try { SourceResolver.resolve(ch.tid, ch.id) } catch (e: Exception) { null } + if (urls.isNullOrEmpty()) { + // 自动重试 3 次(间隔 4 秒),期间给友好提示 + var got: List? = null + for (r in 1..3) { + runOnUiThread { statusText.text = "网络波动,正在重试 ($r/3)…"; loadingText.text = "正在加载,精彩继续…" } + Thread.sleep(4000) + got = try { SourceResolver.resolveLines(ch.tid, ch.id) } catch (e: Exception) { null } + if (!got.isNullOrEmpty()) break + } + if (got.isNullOrEmpty()) { + runOnUiThread { + statusText.text = "⚠ ${ch.num} ${ch.name} 源解析失败\n解析: ${SourceResolver.lastError}" + failureReport = "解析失败 ${SourceResolver.lastError}" + hideLoading() + } + return@Thread + } + val u2 = got!! + runOnUiThread { playUrl(u2[0], ch) } + return@Thread + } + // 不预探测(那是切台慢的主因):直接播对应线路,失败由 onPlayerError 自动换下一条 + var chosen = line % urls.size + val probe = StringBuilder("skip") + val tok = Regex("token=([^&]+)").find(urls[0])?.groupValues?.get(1)?.take(14) ?: "?" + failureReport = "探测: $probe\ntoken: $tok" + lastSelfTest = failureReport + if (chosen < 0) { + // 用的是缓存线路且全失效 → 强制刷新一次再试 + runOnUiThread { statusText.text = "线路失效,正在刷新源…" } + Thread.sleep(2500) + val fresh = try { SourceResolver.resolveFresh(ch.tid, ch.id) } catch (e: Exception) { null } + if (!fresh.isNullOrEmpty() && fresh != urls) { + for (i in fresh.indices) { + val st = probeLine(fresh[i]) + if (st == "200" || st == "206" || st == "302") { chosen = i; break } + } + if (chosen >= 0) { + runOnUiThread { playUrl(fresh[chosen], ch) } + return@Thread + } + } + } + runOnUiThread { + if (chosen < 0) { + statusText.text = "⚠ ${ch.num} ${ch.name} 源站拒绝(全部线路)\n$failureReport" + hideLoading() + } else { + playUrl(urls[chosen], ch) + } + } + }.start() + } + + /** 轻量探测一条线路:跟 302 后取 HTTP 状态码 */ + private fun probeLine(url: String): String = try { + val conn = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply { + requestMethod = "GET"; connectTimeout = 4000; readTimeout = 4000 + setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36") + setRequestProperty("Referer", "https://m.iptv807.com/") + setRequestProperty("Origin", "https://m.iptv807.com") + setRequestProperty("Range", "bytes=0-1") + instanceFollowRedirects = true + } + val code = conn.responseCode + runCatching { conn.inputStream?.close() } + conn.disconnect() + code.toString() + } catch (e: Exception) { "E:${e.javaClass.simpleName}" } + + private fun playUrl(url: String, ch: ChannelData.Channel) { + val isFlv = url.contains("type=.flv") || url.endsWith(".flv") + val item = MediaItem.Builder().setUri(Uri.parse(url)) + .setMimeType(if (isFlv) MimeTypes.VIDEO_FLV else MimeTypes.APPLICATION_M3U8) + .build() + player?.setMediaItem(item) + player?.prepare() + player?.playWhenReady = true + statusText.text = "… ${ch.num} ${ch.name}" + HistoryRecorder.log(this, ch.num, ch.name) + } + + @SuppressLint("SetTextI18n") + private fun autoSwitchLine() { + val ch = channels.getOrNull(currentChannel) ?: return + currentLine++ + if (currentLine > 12) { + val vn = try { packageManager.getPackageInfo(packageName, 0).versionName } catch (e: Exception) { "?" } + statusText.text = "⚠ ${ch.num} ${ch.name} 全部线路失败\n$lastPlayError\n解析: ${SourceResolver.lastError}\n$failureReport\n版本: v$vn" + hideLoading() + return + } + playChannel(currentChannel, currentLine) + } + + private fun onDigit(d: Int) { + digitBuf += d.toString() + digitText.text = digitBuf + digitText.visibility = View.VISIBLE + val t = digitBuf.toIntOrNull() ?: return + val idx = channels.indexOfFirst { it.num == t } + if (idx >= 0) { + playChannel(idx); digitBuf = ""; digitText.visibility = View.GONE; digitTask.cancel() + } else { digitTask.cancel(); digitTask.start() } + } + private fun commitDigit() { + val t = digitBuf.toIntOrNull() ?: return + val idx = channels.indexOfFirst { it.num == t } + if (idx >= 0) playChannel(idx) + digitText.visibility = View.GONE + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + // 「继续播放」按钮可见时,遥控 OK/确认 直接触发 + if (resumeBtn?.visibility == View.VISIBLE && + (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || keyCode == KeyEvent.KEYCODE_SPACE)) { + resumePlayback(); return true + } + if (isPanelVisible()) resetHideTimer() + if (keyCode in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) { onDigit(keyCode - KeyEvent.KEYCODE_0); return true } + if (keyCode == KeyEvent.KEYCODE_NUMPAD_0) { onDigit(0); return true } + if (keyCode in KeyEvent.KEYCODE_NUMPAD_1..KeyEvent.KEYCODE_NUMPAD_9) { onDigit(keyCode - KeyEvent.KEYCODE_NUMPAD_0); return true } + when (keyCode) { + KeyEvent.KEYCODE_MENU -> { + if (isPanelVisible()) { + val list = channelsOfGroup(curGroupPos) + val pos = channelList?.checkedItemPosition ?: -1 + if (pos in 0 until list.size) { + toggleFav(list[pos]) + updateChannelListOfGroup(curGroupPos) + Toast.makeText(this, if (favSet().contains(list[pos].num.toString())) "★ 已收藏 ${list[pos].name}" else "☆ 已取消 ${list[pos].name}", Toast.LENGTH_SHORT).show() + } + return true + } else { autoSwitchLine(); return true } + } + KeyEvent.KEYCODE_CHANNEL_UP -> { playChannel((currentChannel - 1 + channels.size) % channels.size); return true } + KeyEvent.KEYCODE_CHANNEL_DOWN -> { playChannel((currentChannel + 1) % channels.size); return true } + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (isPanelVisible()) { + curGroupPos = (curGroupPos - 1 + groups.size) % groups.size + groupList?.setSelection(curGroupPos + 1); updateChannelListOfGroup(curGroupPos) + return true + } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (isPanelVisible()) { + curGroupPos = (curGroupPos + 1) % groups.size + groupList?.setSelection(curGroupPos + 1); updateChannelListOfGroup(curGroupPos) + return true + } + } + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN -> { + if (isPanelVisible()) return super.onKeyDown(keyCode, event) + playChannel((currentChannel + (if (keyCode == KeyEvent.KEYCODE_DPAD_UP) -1 else 1) + channels.size) % channels.size); return true + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + if (isPanelVisible()) { + val list = channelsOfGroup(curGroupPos) + val pos = channelList?.checkedItemPosition ?: -1 + if (pos >= 0 && pos < list.size) { playChannel(channels.indexOf(list[pos])); hidePanel(); return true } + } + showPanel(); return true + } + KeyEvent.KEYCODE_BACK -> { if (isPanelVisible()) { hidePanel(); return true } } + } + return super.onKeyDown(keyCode, event) + } + + /** 全局手势:连续滑动两次 → 完全退出 App */ + override fun dispatchTouchEvent(ev: android.view.MotionEvent?): Boolean { + try { + if (ev != null) { + when (ev.actionMasked) { + android.view.MotionEvent.ACTION_DOWN -> { + downX = ev.rawX; downY = ev.rawY; downT = System.currentTimeMillis() + } + android.view.MotionEvent.ACTION_UP -> { + val dx = ev.rawX - downX + val dy = ev.rawY - downY + val dist = kotlin.math.hypot(dx.toDouble(), dy.toDouble()).toFloat() + val dt = System.currentTimeMillis() - downT + // 一次有效滑动:位移够大、时间够短 + if (dist > 120f && dt < 800) { + val now = System.currentTimeMillis() + if (now - lastSwipeTs < 1500) { exitApp(); return true } + lastSwipeTs = now + showExitHint() + } + } + } + } + } catch (e: Exception) { } + return super.dispatchTouchEvent(ev) + } + + /** 第一次滑动后提示"再滑一次退出" */ + private fun showExitHint() { + runOnUiThread { + if (exitHint == null) { + exitHint = TextView(this).apply { + textSize = 18f; setTextColor(Color.WHITE) + setBackgroundColor(0xB3000000u.toInt()) + setPadding(40, 20, 40, 20); gravity = Gravity.CENTER + } + (window.decorView as android.view.ViewGroup).addView(exitHint, + android.view.ViewGroup.LayoutParams( + android.view.ViewGroup.LayoutParams.WRAP_CONTENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT).also { + (it as android.widget.FrameLayout.LayoutParams).gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + it.bottomMargin = 80 + }) + } + exitHint?.text = "再滑动一次退出应用" + exitHint?.visibility = View.VISIBLE + handler.removeCallbacks(hideExitHintTask) + handler.postDelayed(hideExitHintTask, 2000) + } + } + private val hideExitHintTask = Runnable { exitHint?.visibility = View.GONE } + + /** 退出前先弹窗确认 */ + private fun exitApp() { + runOnUiThread { + try { + exitHint?.visibility = View.GONE + android.app.AlertDialog.Builder(this) + .setTitle("退出应用") + .setMessage("确定要退出「港澳台直播」吗?") + .setPositiveButton("退出") { _, _ -> doExit() } + .setNegativeButton("取消", null) + .setCancelable(true) + .show() + } catch (e: Exception) { + doExit() // 弹窗异常时直接退出,别卡住 + } + } + } + + /** 真正完全退出 App */ + private fun doExit() { + try { handler.removeCallbacksAndMessages(null) } catch (e: Exception) { } + try { player?.release() } catch (e: Exception) { } + try { finishAndRemoveTask() } catch (e: Exception) { finishAffinity() } + // 彻底结束进程(电视盒子上保证真的退出) + handler.postDelayed({ android.os.Process.killProcess(android.os.Process.myPid()) }, 250) + } + + override fun onResume() { + super.onResume() + // 从桌面/设置返回:先尝试续播,失败则显示「继续播放」按钮 + if (wasStopped) { + wasStopped = false + try { player?.play() } catch (e: Exception) { } + handler.postDelayed({ + // 直播流暂停后常常定格无法续播 → 给用户一个明确的按钮 + if (player?.isPlaying != true) resumeBtn?.visibility = View.VISIBLE + else resumeBtn?.visibility = View.GONE + }, 1800) + } + if (playRequested > 0) { + val idx = channels.indexOfFirst { it.num == playRequested } + playRequested = -1 + if (idx >= 0) playChannel(idx) + } + } + + /** 点「继续播放」→ 重新连接当前频道(直播流最稳的恢复方式) */ + private fun resumePlayback() { + resumeBtn?.visibility = View.GONE + val idx = if (currentChannel in channels.indices) currentChannel else 0 + try { player?.play() } catch (e: Exception) { } + // 等 1.2 秒看是否已恢复,没恢复就重连 + handler.postDelayed({ + if (player?.isPlaying != true) playChannel(idx) + }, 1200) + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideSystemUi() + } + private fun hideSystemUi() { + 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 + } + + override fun onStop() { + wasStopped = true + super.onStop() + player?.pause() + } + + override fun onDestroy() { + digitTask.cancel() + handler.removeCallbacksAndMessages(null) + playerView.player = null + player?.release() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/iptv807/tv/SettingsActivity.kt b/app/src/main/java/com/iptv807/tv/SettingsActivity.kt new file mode 100644 index 0000000..6d114f2 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/SettingsActivity.kt @@ -0,0 +1,152 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.TextView + +/** + * 设置页: + * - 版本号显示 + * - 开机自启动开关(BOOT_COMPLETED receiver) + * - 会员状态显示(已是会员显示有效期;非会员显示成为会员按钮→跳购买) + */ +class SettingsActivity : Activity() { + + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + // crashGuard: 若上次有崩溃标记,先清before检查 + getSharedPreferences("iptv_crash", MODE_PRIVATE).let { + if (it.getBoolean("crashed", false)) { + it.edit().putBoolean("crashed", false).apply() + // 清理license cache以防被污染 + getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply() + } + } + } catch (e: Exception) { } + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#12141A")) + setPadding(96, 64, 96, 64) + } + setContentView(root) + + root.addView(TextView(this).apply { + text = "设置" + textSize = 28f; setTextColor(Color.WHITE) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 48) + }) + + // ===== 版本信息 ===== + val versionName = try { + packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0" + } catch (e: Exception) { "1.0" } + + val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE) + val isVip = prefs.getBoolean("is_vip", false) + val expire = prefs.getString("vip_expire", "") + val permanent = prefs.getBoolean("is_permanent", false) + val cardKey = prefs.getString("card_key", "") + + root.addView(TextView(this).apply { + text = "版本信息" + textSize = 15f; setTextColor(Color.parseColor("#7AA7D9")) + setPadding(0, 0, 0, 8) + }) + root.addView(TextView(this).apply { + text = "App 版本:$versionName\n授权系统:CardAuth(app.88531.cn)\n机器码:${LicenseClient.machineCode(this@SettingsActivity)}" + textSize = 15f; setTextColor(Color.WHITE) + setLineSpacing(0f, 1.15f) + setBackgroundColor(Color.parseColor("#1C2028")) + setPadding(24, 20, 24, 20) + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 40 }) + + // ===== 开机自启动 ===== + root.addView(TextView(this).apply { + text = "通用设置" + textSize = 15f; setTextColor(Color.parseColor("#7AA7D9")) + setPadding(0, 0, 0, 8) + }) + val bootSwitch = Switch(this).apply { + text = "开机自动启动"; textSize = 17f + setTextColor(Color.WHITE) + isChecked = isBootReceiverEnabled() + val pad = (24 * resources.displayMetrics.density).toInt() + setPadding(pad, pad / 2, pad, pad / 2) + setBackgroundColor(Color.parseColor("#1C2028")) + } + bootSwitch.setOnCheckedChangeListener { _, checked -> + val pm = packageManager + val comp = ComponentName(this, BootReceiver::class.java) + pm.setComponentEnabledSetting( + comp, + if (checked) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + root.addView(bootSwitch, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 40 }) + + // ===== 会员状态 ===== + root.addView(TextView(this).apply { + text = "会员状态" + textSize = 15f; setTextColor(Color.parseColor("#7AA7D9")) + setPadding(0, 0, 0, 8) + }) + statusText = TextView(this).apply { + textSize = 15f; setLineSpacing(0f, 1.15f) + setTextColor(Color.WHITE) + setBackgroundColor(Color.parseColor("#1C2028")) + setPadding(24, 20, 24, 20) + text = if (isVip) { + "✓ 已是会员\n${if (permanent) "永久有效" else "有效期至:$expire"}${if (cardKey?.isNotEmpty() == true) "\n卡号:$cardKey" else ""}" + } else { + "当前为免费用户(每天 1 小时)\n尚未成为会员" + } + } + root.addView(statusText, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 24 }) + + + if (isVip) { + root.addView(TextView(this).apply { + text = "✓ 已经是会员" + textSize = 18f; setTextColor(Color.parseColor("#4CAF50")) + gravity = Gravity.CENTER; setPadding(0, 24, 0, 24) + }) + } else { + val btn = Button(this).apply { + text = "支付成为会员(月/季/年)" + textSize = 17f + setBackgroundColor(Color.parseColor("#1B6EF3")) + setOnClickListener { startActivity(Intent(this@SettingsActivity, ShopActivity::class.java)) } + } + root.addView(btn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + } + } + + private fun isBootReceiverEnabled(): Boolean = + packageManager.getComponentEnabledSetting(ComponentName(this, BootReceiver::class.java)) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK) { finish(); return true } + return super.onKeyDown(keyCode, event) + } +} diff --git a/app/src/main/java/com/iptv807/tv/ShopActivity.kt b/app/src/main/java/com/iptv807/tv/ShopActivity.kt new file mode 100644 index 0000000..fd09115 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/ShopActivity.kt @@ -0,0 +1,246 @@ +package com.iptv807.tv + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.TextView +import org.json.JSONArray + +/** + * 套餐商店 —— App 内直接购买 + * 流程: GET card-types → 用户选定套餐+支付方式 → POST /api/public/orders + * (contact_info=MACHINE:TV-xxx) → 拉起浏览器 pay_url 扫码 + * → 轮询 /api/public/orders/query?order_no= → paid → 查询卡号 → 自动verify → 进主界面 + */ +class ShopActivity : Activity() { + + private val plans = mutableListOf() + + data class Plan(val id: Int, val name: String, val price: String, val days: Int) + + private lateinit var list: ListView + private lateinit var statusText: TextView + private var payType = "alipay" + private var orderNo = "" + private var machineId = "" + private val handler = Handler(Looper.getMainLooper()) + private var pollTask: Runnable? = null + private var pollCount = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + // crashGuard: 若上次有崩溃标记,先清before检查 + getSharedPreferences("iptv_crash", MODE_PRIVATE).let { + if (it.getBoolean("crashed", false)) { + it.edit().putBoolean("crashed", false).apply() + // 清理license cache以防被污染 + getSharedPreferences("iptv_license", MODE_PRIVATE).edit().remove("token").remove("card_key").apply() + } + } + } catch (e: Exception) { } + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#12141A")) + setPadding(64, 48, 64, 48) + } + setContentView(root) + root.gravity = Gravity.CENTER + + machineId = LicenseClient.machineCode(this) + + root.addView(TextView(this).apply { + text = "IPTV · 选择套餐" + textSize = 26f; setTextColor(Color.WHITE) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 12) + }) + root.addView(TextView(this).apply { + text = "支付成功自动激活本机(无需卡密)" + textSize = 14f; setTextColor(Color.LTGRAY) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 32) + }) + + list = ListView(this).apply { + setBackgroundColor(Color.parseColor("#1C1F26")) + } + root.addView(list, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f + ).apply { bottomMargin = 24 }) + + statusText = TextView(this).apply { + textSize = 14f; setTextColor(Color.LTGRAY) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 24) + } + root.addView(statusText) + + val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL } + val alipay = Button(this).apply { text = "支付宝"; textSize = 15f } + val wxpay = Button(this).apply { text = "微信"; textSize = 15f } + fun sel(b: Button, t: String) { + val active = payType == t + b.setBackgroundColor(if (active) Color.parseColor("#1B6EF3") else Color.parseColor("#2A2F3A")) + } + alipay.setOnClickListener { payType = "alipay"; sel(alipay, "alipay"); sel(wxpay, "wxpay") } + wxpay.setOnClickListener { payType = "wxpay"; sel(alipay, "alipay"); sel(wxpay, "wxpay") } + sel(alipay, "alipay"); sel(wxpay, "wxpay") + row.addView(alipay, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { rightMargin = 16 }) + row.addView(wxpay, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + root.addView(row) + + list.onItemClickListener = AdapterView.OnItemClickListener { _, _, pos, _ -> + if (pos < plans.size) createOrder(plans[pos]) + } + + loadPlans() + } + + @SuppressLint("SetTextI18n") + private fun loadPlans() { + statusText.text = "加载套餐…" + Thread { + try { + val r = CardAuthApi.get("/api/public/projects/2/card-types") + val arr = r.optJSONArray("data") ?: JSONArray() + plans.clear() + for (i in 0 until arr.length()) { + val p = arr.getJSONObject(i) + // 接口不返回 status 字段时视为启用(默认 1),只有显式 status=0 才跳过 + val st = if (p.has("status")) p.optInt("status", 1) else 1 + if (st == 1) { + val days = p.optInt("duration_days", 0) + plans.add(Plan(p.optInt("id"), p.optString("name"), p.optString("price"), days)) + } + } + if (plans.isEmpty()) { + runOnUiThread { statusText.text = "暂无可用套餐(请稍后重试)" } + } + runOnUiThread { + plans.sortBy { it.days } + list.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, plans.map { + val d = it.days + "📱 ${it.name} ¥${it.price} ${if (d >= 999) "永久" else "${d}天"}" + }) + statusText.text = "选择套餐后按 OK 购买" + } + } catch (e: Exception) { + runOnUiThread { statusText.text = "加载失败: ${e.javaClass.simpleName}: ${e.message?.take(150)}" } + } + }.start() + } + + private fun createOrder(plan: Plan) { + statusText.text = "创建订单…" + Thread { + try { + val body = org.json.JSONObject().apply { + put("project_id", 2) + put("card_type_id", plan.id) + put("pay_type", payType) + put("contact_info", "MACHINE:$machineId") + } + val r = CardAuthApi.post("/api/public/orders", body) + val d = r.optJSONObject("data") + val url = d?.optString("pay_url") ?: "" + runOnUiThread { + if (url.isNotEmpty()) { + orderNo = d!!.optString("order_no", "") + statusText.text = "订单已创建,正在打开支付页…支付成功后自动激活" + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + startPolling() + } else { + statusText.text = "下单失败: ${r.optString("message")}" + } + } + } catch (e: Exception) { + runOnUiThread { statusText.text = "下单错误: ${e.message}" } + } + }.start() + } + + private fun startPolling() { + pollCount = 0 + pollTask = object : Runnable { + override fun run() { + pollCount++ + if (pollCount > 60) { // 最多3分钟 + handler.post { statusText.text = "等待支付超时,可重新下单" } + return + } + Thread { + var paid = false + var cardKey = "" + try { + val r = CardAuthApi.get("/api/public/orders/query?order_no=$orderNo") + val d = r.optJSONObject("data") ?: org.json.JSONObject() + paid = d.optString("status") == "paid" + cardKey = d.optString("card_key", "") + } catch (e: Exception) { } + runOnUiThread { + if (paid) { + statusText.text = "✓ 支付成功!正在激活本机…" + if (cardKey.isNotEmpty()) activateAndEnter(cardKey) + else { // 兜底: 直接用verify免卡路径 + handler.postDelayed({ + getSharedPreferences("iptv_license", MODE_PRIVATE) + .edit().putBoolean("is_vip", true).apply() + enterMain() + }, 500) + } + } else { + statusText.text = "等待支付…(${pollCount * 3}秒)" + handler.postDelayed(this, 3000) + } + } + }.start() + } + } + handler.postDelayed(pollTask!!, 3000) + } + + private fun activateAndEnter(cardKey: String) { + Thread { + val r = LicenseClient.verify(cardKey, machineId) + runOnUiThread { + getSharedPreferences("iptv_license", MODE_PRIVATE).edit() + .putBoolean("is_vip", r.ok) + .putString("card_key", if (r.ok) cardKey else "") + .putString("vip_expire", r.expireTime) + .putBoolean("is_permanent", r.isPermanent) + .apply() + if (r.ok) { + statusText.text = "✓ 激活成功,开始播放" + enterMain() + } else { + statusText.text = "激活校验失败: ${r.message}(可直接输入卡密重试)" + } + } + }.start() + } + + private fun enterMain() { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && pollTask != null) { + // 支付轮询中返回 → 取消轮询 + handler.removeCallbacks(pollTask!!); pollTask = null + } + return super.onKeyDown(keyCode, event) + } +} diff --git a/app/src/main/java/com/iptv807/tv/SourceResolver.kt b/app/src/main/java/com/iptv807/tv/SourceResolver.kt new file mode 100644 index 0000000..e066821 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/SourceResolver.kt @@ -0,0 +1,266 @@ +package com.iptv807.tv + +import android.util.Base64 +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap + +/** + * iptv807 播放源本地解密 —— App 内直接解密,完全不经过 VPS + * + * 解密步骤(Python mirror 端到端 verified): + * 1. GET https://m.iptv807.com/?act=play&tid=X&id=Y (UA=Android mobile, Referer=m.iptv807.com) + * 2. 找 ").findAll(html).map { it.groupValues[1] }.toList() + val mixBlock = blocks.firstOrNull { it.contains(".split(\"\")") } ?: run { + lastError = "no-mix-block"; return null + } + + // 2) quote-aware split by ; + val stmts = StringBuilder() + var inStr = false + for (c in mixBlock) { + when { + c == '"' -> { inStr = !inStr; stmts.append(c) } + c == ';' && !inStr -> { stmts.append('\n'); } + else -> stmts.append(c) + } + } + val statements = stmts.toString().split('\n').map { it.trim() }.filter { it.isNotEmpty() } + + // 3) extensions + val REV = ".split(\"\").reverse().join(\"\")" + fun evalConcat(s: String): String = buildString { + var i = 0 + while (i < s.length) { + if (s[i] == '"') { + var j = i + 1; var lit = "" + while (j < s.length && s[j] != '"') { lit += s[j]; j++ } + if (j >= s.length) { append(lit); i = s.length; break } + val tail = s.substring((j + 1).coerceAtMost(s.length)) + if (tail.startsWith(REV)) { + append(lit.reversed()) + i = j + 1 + REV.length + } else { + append(lit) + i = j + 1 + } + while (i < s.length && (s[i] == '+' || s[i] == ' ')) i++ + } else i++ + } + } + + // 关键:rhs 原样交给 evalConcat(不要剥前导引号/尾部括号,那会破坏字面量配对) + val assign = LinkedHashMap() + for (st in statements) { + if (st.isEmpty()) continue + val mVar = Regex("^var\\s+(\\w+)\\s*=\\s*(.*)$").find(st) + if (mVar != null) { + val name = mVar.groupValues[1] + val rhs = mVar.groupValues[2].trim() + assign[name] = when { + rhs.isEmpty() || rhs == "\"\"" -> "" + Regex("^\"[a-f0-9]{32}\"$").matches(rhs) -> rhs.substring(1, rhs.length - 1) + else -> evalConcat(rhs) + } + continue + } + val mCopy = Regex("^(\\w+)\\s*=\\s*(\\w+)$").find(st) + if (mCopy != null) { + assign[mCopy.groupValues[1]] = assign[mCopy.groupValues[2]] ?: "" + continue + } + val mHex = Regex("^(\\w+)\\s*=\\s*\"([a-f0-9]{32})\"$").find(st) + if (mHex != null) { assign[mHex.groupValues[1]] = mHex.groupValues[2]; continue } + } + + val vals = assign.values.filter { it.isNotEmpty() } + val hex32 = vals.filter { Regex("^[a-f0-9]{32}$").matches(it) } + if (hex32.isEmpty()) { lastError = "no-hex32"; return null } + val xKey = hex32[0] + // ★★ 核心修复2:混淆块里常有 "xx = yy" 复制语句 → hex32[1] 可能 == xKey, + // 那样 replace("token=$oldToken") 就匹配不到 → URL 保留过期旧 token → 全部 404。 + // 必须取"第一个不等于 xKey"的 32hex 才是真正的 oldToken。 + val oldToken = hex32.firstOrNull { it != xKey } ?: xKey + val newToken = vals.firstOrNull { + it.contains(Regex("%[0-9A-Fa-f]{2}")) && + !Regex("^[a-f0-9]{32}$").matches(it) && + !Regex("^[a-f0-9]{16}$").matches(it) && + it != xKey + } ?: run { lastError = "no-newToken"; return null } + + val xorConst = Regex("\"([a-f0-9]{16})\"").findAll(html).map { it.groupValues[1] }.firstOrNull() ?: run { + lastError = "no-xorConst"; return null + } + + // 4) decrypt options + fun decrypt(encrypted: String): String? = try { + val rev = encrypted.reversed() + val b1 = String(Base64.decode(rev, Base64.DEFAULT), charset("ISO-8859-1")) + val key = xKey + xorConst + val sb = StringBuilder() + for (i in b1.indices) { + sb.append(((b1[i].toInt() and 0xFF) xor (key[i % key.length].toInt() and 0xFF)).toChar()) + } + val b2raw = sb.toString() + val b2 = String(Base64.decode(b2raw, Base64.DEFAULT), charset("ISO-8859-1")) + var u = b2.replace("token=$oldToken", "token=$newToken") + u = u.replace(xKey, "") + // 兜底:若 token= 后仍是纯32位hex(旧token没替换成功),强制换成新 token + u = Regex("token=[a-f0-9]{32}").replace(u, "token=$newToken") + // ★★ 核心修复:b2 里 token 已是 %2B 编码形态,绝对不能再 URLDecoder(会把 %2B 解成 '+' 被服务端当空格 → 404) + u + } catch (e: Exception) { null } + + val lines = Regex("