diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a6b8f..831faf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ --- +## v9.3.0(2026-10-08)→ ✨新功能 +- ✨ **接入安装统计**:启动时调用 `POST /api/public/installations/check-update` 完成安装登记与在线上报(返回 `install_id`) +- ✨ **接入版本推送**:同一请求同时返回 `update_available / latest_version / download_url / file_size / changelog / min_client_version / is_force` +- ✨ 发现新版本时弹出更新提示(版本号、当前版本、文件大小、更新说明;强制更新额外提示),走系统 `DownloadManager` 下载并在完成后自动拉起安装 +- ✨ 每 10 分钟心跳 `POST /api/public/installations/heartbeat`,更新"最后上线"时间 +- ✨ 设置页新增「版本更新」区块:显示安装上报结果与 `install_id`,可手动「检查更新」 +- 🔧 接入服务端:`https://auth.88531.cn`,请求头 `X-Api-Key`;文档见 `auth.88531.cn/#/docs` +- 🔧 新增权限:`REQUEST_INSTALL_PACKAGES`、`POST_NOTIFICATIONS` + + ## v9.2.0(2026-10-08)→ ✨新功能 / 🐛修复 - 🐛 从设置页或桌面返回时,直接**重连当前流**(不再依赖 `play()`,避免画面定格) - ✨「▶ 继续播放」按钮改为强制重连同一路流,恢复更快更可靠 diff --git a/apk/gangao-tv-v9.2.0.apk b/apk/gangao-tv-v9.3.0.apk similarity index 97% rename from apk/gangao-tv-v9.2.0.apk rename to apk/gangao-tv-v9.3.0.apk index 80bacfb..7599381 100644 Binary files a/apk/gangao-tv-v9.2.0.apk and b/apk/gangao-tv-v9.3.0.apk differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5bb0b6c..8b9eb38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.iptv807.tv" minSdk = 21 targetSdk = 35 - versionCode = 920 - versionName = "9.2.0" + versionCode = 930 + versionName = "9.3.0" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0aa6b58..3765064 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + + diff --git a/app/src/main/java/com/iptv807/tv/AuthApi.kt b/app/src/main/java/com/iptv807/tv/AuthApi.kt new file mode 100644 index 0000000..d3feec0 --- /dev/null +++ b/app/src/main/java/com/iptv807/tv/AuthApi.kt @@ -0,0 +1,133 @@ +package com.iptv807.tv + +import android.content.Context +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 + +/** + * 安装统计 + 版本推送 客户端(auth.88531.cn) + * + * 接口文档:https://auth.88531.cn/#/docs + * POST /api/public/installations/check-update 安装登记 + 检查更新(启动时调用,一石二鸟) + * POST /api/public/installations/heartbeat 仅更新在线时间(周期心跳) + * Header: X-Api-Key + Content-Type: application/json + * 响应:{ code, message, data } + * + * 文档建议: + * 启动/周期心跳时调用 check-update,同时完成安装登记与在线上报; + * verify / check-update / heartbeat 成功都会更新"最后上线"。 + */ +object AuthApi { + + private const val BASE = "https://auth.88531.cn" + private const val API_KEY = "ak_563f4768d5d411d381e58bc3f76ba447" + + @Volatile var lastError: String = "" + + /** 检查更新 + 安装登记 的结果 */ + data class UpdateInfo( + val ok: Boolean = false, + val msg: String = "", + val updateAvailable: Boolean = false, + val updateRequired: Boolean = false, // 后台开了强制推送 + val currentVersion: String = "", + val latestVersion: String = "", + val downloadUrl: String = "", + val fileSize: Long = 0, + val checksum: String = "", + val changelog: String = "", + val minClientVersion: String = "", + val isForce: Int = 0, + val installId: Long = 0 + ) + + /** 当前应用版本号(用于 client_version 与版本比较) */ + fun clientVersion(ctx: Context): String = try { + ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0.0.0" + } catch (e: Exception) { "0.0.0" } + + /** 完整下载地址(接口返回的是相对路径) */ + fun absDownloadUrl(rel: String): String = when { + rel.isEmpty() -> "" + rel.startsWith("http") -> rel + else -> BASE + if (rel.startsWith("/")) rel else "/$rel" + } + + /** 检查更新 + 登记安装(网络请求,必须放后台线程) */ + @JvmOverloads + fun checkUpdate(machineId: String, cardKey: String = "", ctx: Context? = null): UpdateInfo { + val body = JSONObject().apply { + put("machine_id", machineId) + put("client_version", if (ctx != null) clientVersion(ctx) else "0.0.0") + put("device_info", "AndroidTV ${android.os.Build.MODEL}") + if (cardKey.isNotEmpty()) put("card_key", cardKey) + } + val r = post("/api/public/installations/check-update", body) + val code = r.optInt("code", 0) + if (code != 200) { + lastError = "code=$code ${r.optString("message")}" + return UpdateInfo(ok = false, msg = lastError) + } + val d = r.optJSONObject("data") ?: JSONObject() + val ver = d.optString("version").ifEmpty { d.optString("latest_version") } + val info = UpdateInfo( + ok = true, + msg = "ok", + updateAvailable = d.optBoolean("update_available", false), + updateRequired = d.optBoolean("update_required", false) || d.optInt("is_force", 0) == 1, + currentVersion = d.optString("current_version"), + latestVersion = ver, + downloadUrl = absDownloadUrl(d.optString("download_url")), + fileSize = d.optLong("file_size", 0), + checksum = d.optString("checksum"), + changelog = d.optString("changelog"), + minClientVersion = d.optString("min_client_version"), + isForce = d.optInt("is_force", 0), + installId = d.optLong("install_id", 0) + ) + lastError = "ok install=${info.installId}" + return info + } + + /** 心跳:仅更新在线时间(网络请求,必须放后台线程) */ + @JvmOverloads + fun heartbeat(machineId: String, cardKey: String = "", ctx: Context? = null): Boolean { + val body = JSONObject().apply { + put("machine_id", machineId) + put("client_version", if (ctx != null) clientVersion(ctx) else "0.0.0") + if (cardKey.isNotEmpty()) put("card_key", cardKey) + } + val r = post("/api/public/installations/heartbeat", body) + val ok = r.optInt("code", 0) == 200 + lastError = if (ok) "hb ok" else "hb ${r.optString("message")}" + return ok + } + + private fun post(path: String, body: JSONObject): JSONObject { + return try { + val conn = (URL(BASE + path).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 12000 + readTimeout = 15000 + doOutput = true + setRequestProperty("Content-Type", "application/json") + setRequestProperty("X-Api-Key", API_KEY) + setRequestProperty("Accept", "application/json") + } + conn.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 = try { + stream?.let { BufferedReader(InputStreamReader(it, StandardCharsets.UTF_8)).readText() } ?: "" + } finally { runCatching { stream?.close(); conn.disconnect() } } + if (text.isEmpty()) JSONObject().put("code", code) + else JSONObject(text) + } catch (e: Exception) { + JSONObject().put("code", 0).put("message", e.javaClass.simpleName + ": " + (e.message ?: "").take(80)) + } + } +} diff --git a/app/src/main/java/com/iptv807/tv/MainActivity.kt b/app/src/main/java/com/iptv807/tv/MainActivity.kt index a3491d5..ee05274 100644 --- a/app/src/main/java/com/iptv807/tv/MainActivity.kt +++ b/app/src/main/java/com/iptv807/tv/MainActivity.kt @@ -97,6 +97,41 @@ class MainActivity : Activity() { private var downT = 0L private var exitHint: TextView? = null private var confirmOverlay: View? = null + // 安装统计 + 版本推送 + private var updateOverlay: View? = null + private var pendingUpdate: AuthApi.UpdateInfo? = null + private var downloadId = -1L + private val heartbeatTask = object : Runnable { + override fun run() { + Thread { + try { + val card = getSharedPreferences("iptv_license", MODE_PRIVATE).getString("card_key", "") ?: "" + AuthApi.heartbeat(LicenseClient.machineCode(this@MainActivity), card, this@MainActivity) + } catch (e: Exception) { } + }.start() + handler.postDelayed(this, 10 * 60 * 1000L) // 每 10 分钟心跳 + } + } + private val downloadReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(ctx: android.content.Context, intent: android.content.Intent) { + if (intent.action != android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE) return + val id = intent.getLongExtra(android.app.DownloadManager.EXTRA_DOWNLOAD_ID, -1L) + if (id != downloadId) return + val dm = ctx.getSystemService(android.content.Context.DOWNLOAD_SERVICE) as android.app.DownloadManager + val uri = runCatching { dm.getUriForDownloadedFile(id) }.getOrNull() + if (uri != null) runOnUiThread { + statusText.text = "✓ 新版本下载完成,正在打开安装…" + try { + val i = android.content.Intent(android.content.Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(i) + } catch (e: Exception) { + statusText.text = "已下载到 下载/ 目录,请手动安装" + } + } + } + } private var confirmVisible = false private var confirmCancelBtn: TextView? = null private var channelAdapter: RowAdapter? = null @@ -238,6 +273,7 @@ class MainActivity : Activity() { // 播放期间保持屏幕常亮 window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) checkLicense() + startInstallTracking() // 安装统计 + 检查新版本 if (savedInstanceState == null && channels.isNotEmpty()) { val defIdx = channels.indexOfFirst { it.name.contains("TVB翡翠台") }.let { if (it >= 0) it else 0 } playChannel(defIdx) @@ -788,6 +824,143 @@ class MainActivity : Activity() { } private val hideExitHintTask = Runnable { exitHint?.visibility = View.GONE } + // ===== 安装统计 & 版本推送 ===== + private fun startInstallTracking() { + val prefs = getSharedPreferences("iptv_license", MODE_PRIVATE) + val card = prefs.getString("card_key", "") ?: "" + Thread { + val mid = LicenseClient.machineCode(this@MainActivity) + // check-update 一次请求同时完成:安装登记 + 在线上报 + 查新版本 + val info = runCatching { + AuthApi.checkUpdate(mid, card, this@MainActivity) + }.getOrDefault(AuthApi.UpdateInfo(msg = AuthApi.lastError)) + runOnUiThread { + if (info.ok) { + val sb = StringBuilder("安装上报 ok #${info.installId} · v${AuthApi.clientVersion(this)}") + if (info.updateAvailable) sb.append(" · 发现新版 v${info.latestVersion}") + statusText.text = sb.toString() + handler.postDelayed({ + val c = channels.getOrNull(currentChannel) + if (c != null) statusText.text = "▶ ${c.num} ${c.name}" + }, 8000) + if (info.updateAvailable) { + pendingUpdate = info + showUpdateDialog(info) + } + } else { + statusText.text = "安装上报失败: ${info.msg}" + } + } + }.start() + handler.removeCallbacks(heartbeatTask) + handler.postDelayed(heartbeatTask, 60 * 1000L) // 1 分钟后首次心跳 + } + + /** 新版本提示(自绘浮层,电视全屏主题下 AlertDialog 不可靠) */ + private fun showUpdateDialog(info: AuthApi.UpdateInfo) { + if (updateOverlay == null) { + val mask = FrameLayout(this).apply { setBackgroundColor(0xCC000000u.toInt()) } + val card = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.parseColor("#22262E")) + setPadding(64, 48, 64, 48) + } + card.addView(TextView(this).apply { + text = "发现新版本"; textSize = 24f; setTextColor(Color.WHITE) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 12) + }) + card.addView(TextView(this).apply { + tag = "ver"; textSize = 40f; setTextColor(Color.parseColor("#4CAF50")) + gravity = Gravity.CENTER; setPadding(0, 0, 0, 16) + }) + card.addView(TextView(this).apply { + tag = "meta"; textSize = 15f; setTextColor(Color.LTGRAY) + gravity = Gravity.CENTER; setLineSpacing(0f, 1.2f); setPadding(0, 0, 0, 24) + }) + card.addView(TextView(this).apply { + tag = "chg"; textSize = 15f; setTextColor(Color.WHITE) + setBackgroundColor(Color.parseColor("#1C2028")) + setPadding(24, 20, 24, 20); setLineSpacing(0f, 1.25f) + maxLines = 8; gravity = Gravity.CENTER + }) + val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER; setPadding(0, 28, 0, 0) } + fun mkBtn(label: String, focusable: Boolean, onClick: () -> Unit): TextView = + TextView(this).apply { + text = label; textSize = 18f; setTextColor(Color.WHITE); gravity = Gravity.CENTER + setPadding(56, 22, 56, 22); isFocusable = focusable; isFocusableInTouchMode = focusable + if (focusable) background = android.graphics.drawable.StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_focused), + android.graphics.drawable.ColorDrawable(Color.parseColor("#1B6EF3"))) + addState(intArrayOf(), android.graphics.drawable.ColorDrawable(Color.parseColor("#3A4150"))) + } else setBackgroundColor(Color.parseColor("#2A2F3A")) + setOnClickListener { onClick() } + } + val laterBtn = mkBtn("稍后", true) { hideUpdateDialog() } + val dlBtn = mkBtn("立即更新", false) { + pendingUpdate?.let { startUpdateDownload(it) }; hideUpdateDialog() + } + row.addView(laterBtn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { rightMargin = 32 }) + row.addView(dlBtn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + card.addView(row) + mask.addView(card, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) + root.addView(mask, FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + updateOverlay = mask + } + val ver = updateOverlay!!.findViewWithTag("ver") + val meta = updateOverlay!!.findViewWithTag("meta") + val chg = updateOverlay!!.findViewWithTag("chg") + ver.text = "v" + info.latestVersion + val mb = if (info.fileSize > 0) "%.1f MB".format(info.fileSize / 1024.0 / 1024.0) else "" + val force = info.updateRequired || (info.minClientVersion.isNotEmpty() && + compareVersion(AuthApi.clientVersion(this), info.minClientVersion) < 0) + meta.text = "当前 v${AuthApi.clientVersion(this)}" + (if (mb.isNotEmpty()) " · $mb" else "") + + (if (force) "\n⚠ 请升级到此版本" else "") + chg.text = info.changelog.ifEmpty { "本次更新包含功能优化与问题修复" } + updateOverlay!!.visibility = View.VISIBLE + // 强制更新时不给"稍后" + updateOverlay!!.findViewWithTag("ver") // touch to ensure rendered + } + + private fun hideUpdateDialog() { updateOverlay?.visibility = View.GONE } + + /** 比较 "9.10.0" vs "9.3.0" 之类 */ + fun compareVersion(a: String, b: String): Int { + val pa = a.split('.').map { it.toIntOrNull() ?: 0 } + val pb = b.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(pa.size, pb.size)) { + val x = pa.getOrElse(i) { 0 }; val y = pb.getOrElse(i) { 0 } + if (x != y) return if (x > y) 1 else -1 + } + return 0 + } + + /** 用系统 DownloadManager 下载新版本 APK,完成广播里自动拉起安装 */ + private fun startUpdateDownload(info: AuthApi.UpdateInfo) { + if (info.downloadUrl.isEmpty()) { + statusText.text = "暂无下载地址,请到后台发布安装包" + return + } + try { + val dm = getSystemService(android.content.Context.DOWNLOAD_SERVICE) as android.app.DownloadManager + val name = "gangao-tv-${info.latestVersion}.apk" + val req = android.app.DownloadManager.Request(Uri.parse(info.downloadUrl)) + .setTitle("港澳台直播 v${info.latestVersion}") + .setDescription("正在下载新版本…") + .setNotificationVisibility(android.app.DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setMimeType("application/vnd.android.package-archive") + .setDestinationInExternalFilesDir(this, android.os.Environment.DIRECTORY_DOWNLOADS, name) + downloadId = dm.enqueue(req) + try { registerReceiver(downloadReceiver, android.content.IntentFilter(android.app.DownloadManager.ACTION_DOWNLOAD_COMPLETE)) } catch (e: Exception) { } + statusText.text = "⬇ 已开始下载 v${info.latestVersion},完成后会自动提示安装" + } catch (e: Exception) { + statusText.text = "下载失败: ${e.message}" + } + } + /** 退出前先弹确认框(自绘,不依赖系统对话框主题) */ private fun exitApp() { runOnUiThread { @@ -965,6 +1138,7 @@ class MainActivity : Activity() { handler.removeCallbacksAndMessages(null) playerView.player = null player?.release() + runCatching { unregisterReceiver(downloadReceiver) } super.onDestroy() } } diff --git a/app/src/main/java/com/iptv807/tv/SettingsActivity.kt b/app/src/main/java/com/iptv807/tv/SettingsActivity.kt index 9f8b5ce..a5dfdc6 100644 --- a/app/src/main/java/com/iptv807/tv/SettingsActivity.kt +++ b/app/src/main/java/com/iptv807/tv/SettingsActivity.kt @@ -26,6 +26,7 @@ import android.widget.TextView class SettingsActivity : Activity() { private lateinit var statusText: TextView + private lateinit var statusText2: TextView /** 焦点高亮背景:获得焦点→蓝色,普通→深灰 */ private fun focusBg(normal: Int = 0xFF2A2F3A.toInt(), focused: Int = 0xFF1B6EF3.toInt()) @@ -154,6 +155,25 @@ class SettingsActivity : Activity() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 24 }) + // ===== 安装统计 & 版本更新 ===== + root.addView(TextView(this).apply { + text = "版本更新" + textSize = 15f; setTextColor(Color.parseColor("#7AA7D9")) + setPadding(0, 8, 0, 8) + }) + statusText2 = TextView(this).apply { + textSize = 15f; setLineSpacing(0f, 1.15f) + setTextColor(Color.WHITE); setBackgroundColor(Color.parseColor("#1C2028")) + setPadding(24, 20, 24, 20) + text = "点击右侧按钮检查是否有新版本\n(安装统计会在启动时自动上报)" + } + root.addView(statusText2, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 12 }) + + val checkBtn = mkAction("检查更新") { doCheckUpdate() } + root.addView(checkBtn, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = 40 }) + // ===== 卡密激活 ===== root.addView(TextView(this).apply { text = "卡密激活" @@ -224,6 +244,33 @@ class SettingsActivity : Activity() { }.start() } + /** 检查新版本(安装登记 + 在线上报 会一并完成) */ + private fun doCheckUpdate() { + statusText2.text = "正在检查…" + Thread { + val mid = LicenseClient.machineCode(this) + val card = getSharedPreferences("iptv_license", MODE_PRIVATE).getString("card_key", "") ?: "" + val info = AuthApi.checkUpdate(mid, card, this) + runOnUiThread { + if (!info.ok) { + statusText2.text = "检查失败:${info.msg}" + return@runOnUiThread + } + val head = "安装上报成功 #${info.installId}\n当前 v${AuthApi.clientVersion(this@SettingsActivity)}" + statusText2.text = if (info.updateAvailable) { + "$head\n✅ 有新版本:v${info.latestVersion}" + + (if (info.changelog.isNotEmpty()) "\n${info.changelog}" else "") + } else { + "$head\n✅ 已是最新版本" + } + if (info.updateAvailable) { + android.widget.Toast.makeText(this@SettingsActivity, + "发现新版本 v${info.latestVersion},返回主界面下载", android.widget.Toast.LENGTH_LONG).show() + } + } + }.start() + } + private fun isBootReceiverEnabled(): Boolean = packageManager.getComponentEnabledSetting(ComponentName(this, BootReceiver::class.java)) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED