From ec67a64bb9c4e1f8bfb40f02ade4b0471e8151f1 Mon Sep 17 00:00:00 2001 From: yaoxieyoulei <1622968661@qq.com> Date: Tue, 16 Apr 2024 15:17:14 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E8=87=AA=E5=8A=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 2 +- .../mytv/data/entities/GithubRelease.kt | 7 + .../data/repositories/GithubRepository.kt | 7 + .../data/repositories/GithubRepositoryImpl.kt | 46 +++++ .../top/yogiczy/mytv/data/utils/Constants.kt | 7 +- .../ui/screens/home/components/HomeContent.kt | 7 +- .../ui/screens/settings/SettingsScreen.kt | 5 +- .../settings/components/SettingsList.kt | 15 +- .../settings/components/SettingsMain.kt | 3 +- .../settings/components/UpdateState.kt | 173 ++++++++++++++++++ .../top/yogiczy/mytv/ui/utils/ApkInstaller.kt | 41 +++++ .../top/yogiczy/mytv/ui/utils/DownloadUtil.kt | 74 ++++++++ .../top/yogiczy/mytv/ui/utils/VersionUtil.kt | 17 ++ 13 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/top/yogiczy/mytv/data/entities/GithubRelease.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/data/repositories/GithubRepository.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/data/repositories/GithubRepositoryImpl.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/UpdateState.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/ui/utils/ApkInstaller.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/ui/utils/DownloadUtil.kt create mode 100644 app/src/main/java/top/yogiczy/mytv/ui/utils/VersionUtil.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 49f1b9a5..499cea70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,7 +51,7 @@ if (channelNo.toInt() - 1 in 0.. Unit = {}, ) { val childPadding = rememberChildPadding() @@ -53,7 +56,7 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(20.dp)) - SettingsMain() + SettingsMain(updateState = updateState) } } } diff --git a/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsList.kt b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsList.kt index 405c4e30..3dac74b8 100644 --- a/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsList.kt +++ b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsList.kt @@ -9,12 +9,14 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyRow +import kotlinx.coroutines.launch import top.yogiczy.mytv.ui.rememberChildPadding import top.yogiczy.mytv.ui.theme.MyTVTheme import top.yogiczy.mytv.ui.utils.SP @@ -22,6 +24,7 @@ import top.yogiczy.mytv.ui.utils.SP @Composable fun SettingsList( modifier: Modifier = Modifier, + updateState: UpdateState = rememberUpdateState(), ) { val childPadding = rememberChildPadding() @@ -58,11 +61,17 @@ fun SettingsList( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { item { + val coroutineScope = rememberCoroutineScope() + SettingsItem( title = "应用更新", - value = "无更新", - description = "最新版本:v1.0.0", - onClick = { }, + value = if (updateState.isUpdateAvailable) "新版本" else "无更新", + description = "最新版本:${updateState.latestRelease.tagName}", + onLongClick = { + coroutineScope.launch { + updateState.downloadAndUpdate() + } + }, ) } diff --git a/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsMain.kt b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsMain.kt index 1d96356e..bd6cd3a8 100644 --- a/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsMain.kt +++ b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/SettingsMain.kt @@ -20,6 +20,7 @@ import top.yogiczy.mytv.ui.utils.SP @Composable fun SettingsMain( modifier: Modifier = Modifier, + updateState: UpdateState = rememberUpdateState(), ) { val childPadding = rememberChildPadding() @@ -31,7 +32,7 @@ fun SettingsMain( modifier = Modifier.padding(start = childPadding.start), ) Spacer(modifier = Modifier.height(6.dp)) - SettingsList() + SettingsList(updateState = updateState) } } diff --git a/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/UpdateState.kt b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/UpdateState.kt new file mode 100644 index 00000000..bbde0640 --- /dev/null +++ b/app/src/main/java/top/yogiczy/mytv/ui/screens/settings/components/UpdateState.kt @@ -0,0 +1,173 @@ +package top.yogiczy.mytv.ui.screens.settings.components + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import top.yogiczy.mytv.data.entities.GithubRelease +import top.yogiczy.mytv.data.repositories.GithubRepositoryImpl +import top.yogiczy.mytv.data.utils.Constants +import top.yogiczy.mytv.ui.utils.ApkInstaller +import top.yogiczy.mytv.ui.utils.DownloadUtil +import top.yogiczy.mytv.ui.utils.VersionUtil +import java.io.File + +@Stable +data class UpdateState( + private val context: Context, + private val packageInfo: PackageInfo, + private val coroutineScope: CoroutineScope, + val latestFile: File = File(context.cacheDir, "latest.apk"), +) { + private var _isChecking by mutableStateOf(false) + val isChecking get() = _isChecking + + private var _isUpdating by mutableStateOf(false) + val isUpdating get() = _isUpdating + + private var _isUpdateAvailable by mutableStateOf(false) + val isUpdateAvailable get() = _isUpdateAvailable + + private var _updateDownloaded by mutableStateOf(false) + val updateDownloaded get() = _updateDownloaded + + private var _latestRelease by mutableStateOf(GithubRelease()) + val latestRelease get() = _latestRelease + + suspend fun checkUpdate() { + if (_isChecking) return + if (_isUpdateAvailable) return + + try { + _isChecking = true + _latestRelease = GithubRepositoryImpl().latestRelease() + if (VersionUtil.compareVersion( + _latestRelease.tagName.substring(1), + packageInfo.versionName + ) > 0 + ) { + _isUpdateAvailable = true + Toast.makeText( + context, + "新版本: ${_latestRelease.tagName}", + Toast.LENGTH_SHORT + ) + .show() + } + } catch (e: Exception) { + Log.e("UpdateState", e.message ?: e.toString(), e) + Toast.makeText(context, "检查更新失败", Toast.LENGTH_SHORT).show() + } finally { + _isChecking = false + } + } + + suspend fun downloadAndUpdate() { + if (!_isUpdateAvailable) return + if (_isUpdating) return + + try { + _isUpdating = true + _updateDownloaded = false + + var toast = Toast.makeText( + context, "开始下载更新: ${_latestRelease.tagName}", Toast.LENGTH_SHORT + ).apply { show() } + + DownloadUtil.downloadTo( + "${Constants.GITHUB_PROXY}${_latestRelease.downloadUrl}", + latestFile.path, + downloadListener = object : DownloadUtil.DownloadListener() { + var lastTime = 0L + override fun onProgress(progress: Int) { + coroutineScope.launch { + if (System.currentTimeMillis() - lastTime > 1000) { + lastTime = System.currentTimeMillis() + toast.cancel() + toast = Toast.makeText( + context, + "正在下载更新: $progress%", + Toast.LENGTH_SHORT + ).apply { show() } + } + } + } + } + ) + + _updateDownloaded = true + toast.cancel() + Toast.makeText( + context, "下载更新成功: ${_latestRelease.tagName}", Toast.LENGTH_SHORT + ).show() + + } catch (e: Exception) { + Toast.makeText(context, "下载更新失败", Toast.LENGTH_SHORT).show() + } finally { + _isUpdating = false + } + } +} + +@Composable +fun rememberUpdateState( + context: Context = LocalContext.current, +): UpdateState { + val coroutineScope = rememberCoroutineScope() + val packageInfo = rememberPackageInfo() + + val state = remember { + UpdateState( + context = context, + packageInfo = packageInfo, + coroutineScope = coroutineScope, + ) + } + + LaunchedEffect(Unit) { + state.checkUpdate() + } + + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (context.packageManager.canRequestPackageInstalls()) + ApkInstaller.installApk(context, state.latestFile.path) + else + Toast.makeText(context, "未授予安装权限", Toast.LENGTH_SHORT).show() + } + } + + LaunchedEffect(state.updateDownloaded) { + if (!state.updateDownloaded) return@LaunchedEffect + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + ApkInstaller.installApk(context, state.latestFile.path) + } else { + if (context.packageManager.canRequestPackageInstalls()) { + ApkInstaller.installApk(context, state.latestFile.path) + } else { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + launcher.launch(intent) + } + } + } + + return state +} \ No newline at end of file diff --git a/app/src/main/java/top/yogiczy/mytv/ui/utils/ApkInstaller.kt b/app/src/main/java/top/yogiczy/mytv/ui/utils/ApkInstaller.kt new file mode 100644 index 00000000..796f1d41 --- /dev/null +++ b/app/src/main/java/top/yogiczy/mytv/ui/utils/ApkInstaller.kt @@ -0,0 +1,41 @@ +package top.yogiczy.mytv.ui.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import java.io.File + +object ApkInstaller { + @SuppressLint("SetWorldReadable") + fun installApk(context: Context, filePath: String) { + val file = File(filePath) + if (file.exists()) { + val cacheDir = context.cacheDir + val cachedApkFile = File(cacheDir, file.name).apply { + writeBytes(file.readBytes()) + // 解决Android6 无法解析安装包 + setReadable(true, false) + } + + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile( + context, + context.packageName + ".FileProvider", + cachedApkFile + ) + } else { + Uri.fromFile(cachedApkFile) + } + + val installIntent = Intent(Intent.ACTION_VIEW).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + setDataAndType(uri, "application/vnd.android.package-archive") + } + + context.startActivity(installIntent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/top/yogiczy/mytv/ui/utils/DownloadUtil.kt b/app/src/main/java/top/yogiczy/mytv/ui/utils/DownloadUtil.kt new file mode 100644 index 00000000..368f4fb5 --- /dev/null +++ b/app/src/main/java/top/yogiczy/mytv/ui/utils/DownloadUtil.kt @@ -0,0 +1,74 @@ +package top.yogiczy.mytv.ui.utils + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.Buffer +import okio.BufferedSource +import okio.ForwardingSource +import okio.buffer +import java.io.File +import java.io.FileOutputStream + +object DownloadUtil { + suspend fun downloadTo(url: String, filePath: String, downloadListener: DownloadListener?) = + withContext(Dispatchers.IO) { + Log.d(TAG, "下载文件: $url") + + val interceptor = Interceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(DownloadResponseBody(originalResponse, downloadListener)).build() + } + + val client = OkHttpClient.Builder().addNetworkInterceptor(interceptor).build() + val request = Request.Builder().url(url).build() + + try { + with(client.newCall(request).execute()) { + if (!isSuccessful) { + throw Exception("下载文件失败: $code") + } + + val file = File(filePath) + FileOutputStream(file).use { fos -> fos.write(body!!.bytes()) } + } + } catch (e: Exception) { + Log.e(TAG, "下载文件失败", e) + throw Exception("下载文件失败,请检查网络连接", e.cause) + } + } + + private class DownloadResponseBody( + private val originalResponse: okhttp3.Response, + private val downloadListener: DownloadListener?, + ) : okhttp3.ResponseBody() { + override fun contentLength() = originalResponse.body!!.contentLength() + + override fun contentType() = originalResponse.body?.contentType() + + override fun source(): BufferedSource { + return object : ForwardingSource(originalResponse.body!!.source()) { + var totalBytesRead = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + val progress = (totalBytesRead * 100 / contentLength()).toInt() + downloadListener?.onProgress(progress) + return bytesRead + } + }.buffer() + } + + } + + abstract class DownloadListener { + open fun onProgress(progress: Int) {} + } + + private const val TAG = "DownloadUtil" +} \ No newline at end of file diff --git a/app/src/main/java/top/yogiczy/mytv/ui/utils/VersionUtil.kt b/app/src/main/java/top/yogiczy/mytv/ui/utils/VersionUtil.kt new file mode 100644 index 00000000..268700c8 --- /dev/null +++ b/app/src/main/java/top/yogiczy/mytv/ui/utils/VersionUtil.kt @@ -0,0 +1,17 @@ +package top.yogiczy.mytv.ui.utils + +object VersionUtil { + fun compareVersion(version1: String, version2: String): Int { + val v1 = version1.split(".").map { it.toInt() } + val v2 = version2.split(".").map { it.toInt() } + val maxLength = maxOf(v1.size, v2.size) + for (i in 0 until maxLength) { + if (v1.getOrElse(i) { 0 } > v2.getOrElse(i) { 0 }) + return 1 + else if (v1.getOrElse(i) { 0 } < v2.getOrElse(i) { 0 }) + return -1 + } + + return 0 + } +} \ No newline at end of file