اصلاح سیستم دانلود به‌روزرسانی با Ktor و حل مشکل گیر کردن در ۰٪

This commit is contained in:
Oqba committed 2026-09-21 18:08:35 +03:30
1 parent ed1d3cecbb
commit ebf9798c16
2 files changed
+108 -101

No files matched your search

@@ -1,10 +1,13 @@
@file:OptIn(ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class)
package com.oqba26.monthlypaymentapp package com.oqba26.monthlypaymentapp
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -96,6 +99,7 @@ import com.oqba26.monthlypaymentapp.ui.screens.PersonDetailScreen
import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
import com.oqba26.monthlypaymentapp.utils.DownloadState
import com.oqba26.monthlypaymentapp.utils.UpdateInfo import com.oqba26.monthlypaymentapp.utils.UpdateInfo
import com.oqba26.monthlypaymentapp.utils.UpdateManager import com.oqba26.monthlypaymentapp.utils.UpdateManager
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
@@ -213,23 +217,40 @@ fun MainAppHost(viewModel: PersonViewModel) {
updateInfo = info, updateInfo = info,
onDismiss = { updateInfo = null }, onDismiss = { updateInfo = null },
onConfirm = { onConfirm = {
val id = updateManager.downloadAndInstall(info.url, "MonthlyPaymentApp_v${info.versionName}.apk") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
if (id != -1L) { Toast.makeText(context, "لطفاً اجازه نصب برنامه‌های ناشناخته را بدهید", Toast.LENGTH_LONG).show()
isDownloading = true val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
scope.launch { data = "package:${context.packageName}".toUri()
updateManager.getDownloadProgress(id).collect { progress -> flags = Intent.FLAG_ACTIVITY_NEW_TASK
downloadProgress = progress }
if (progress >= 1f) { context.startActivity(intent)
return@UpdateDialog
}
isDownloading = true
downloadProgress = 0f
if (!info.isForceUpdate) {
updateInfo = null
}
scope.launch {
val fileName = "MonthlyPaymentApp_v${info.versionName}.apk"
updateManager.downloadApk(info.url, fileName).collect { state ->
when (state) {
is DownloadState.Progress -> {
downloadProgress = state.progress
}
is DownloadState.Success -> {
isDownloading = false isDownloading = false
if (!info.isForceUpdate) { updateManager.installApk(state.file)
updateInfo = null }
} is DownloadState.Error -> {
isDownloading = false
Toast.makeText(context, "خطا در دانلود: ${state.message}", Toast.LENGTH_LONG).show()
updateManager.openInBrowser(info.url)
} }
} }
} }
if (!info.isForceUpdate) {
updateInfo = null
}
} }
} }
) )
@@ -252,15 +273,23 @@ fun MainAppHost(viewModel: PersonViewModel) {
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
LinearProgressIndicator( if (downloadProgress > 0f) {
progress = { downloadProgress }, LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(8.dp), progress = { downloadProgress },
color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth().height(8.dp),
trackColor = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.primary,
) trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "${(downloadProgress * 100).toInt()}%", text = if (downloadProgress > 0f) "${(downloadProgress * 100).toInt()}%" else "در حال اتصال...",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -1,34 +1,31 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) @file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.utils package com.oqba26.monthlypaymentapp.utils
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.statement.bodyAsText
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.milliseconds
@Serializable @Serializable
data class UpdateInfo( data class UpdateInfo(
@@ -39,6 +36,12 @@ data class UpdateInfo(
val isForceUpdate: Boolean = false, val isForceUpdate: Boolean = false,
) )
sealed class DownloadState {
data class Progress(val progress: Float) : DownloadState()
data class Success(val file: File) : DownloadState()
data class Error(val message: String) : DownloadState()
}
class UpdateManager(private val context: Context) { class UpdateManager(private val context: Context) {
private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json" private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
@@ -81,90 +84,53 @@ class UpdateManager(private val context: Context) {
null null
} }
fun downloadAndInstall(url: String, fileName: String): Long { fun downloadApk(url: String, fileName: String): Flow<DownloadState> = flow {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try {
if (!context.packageManager.canRequestPackageInstalls()) { val destinationFile = File(
Toast.makeText(context, "لطفاً اجازه نصب برنامه‌های ناشناخته را بدهید", Toast.LENGTH_LONG).show() context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { fileName
data = "package:${context.packageName}".toUri() )
flags = Intent.FLAG_ACTIVITY_NEW_TASK if (destinationFile.exists()) {
} destinationFile.delete()
context.startActivity(intent)
return -1L
} }
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager client.prepareGet(url) {
val uri = url.toUri() header(HttpHeaders.UserAgent, "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
}.execute { response ->
if (!response.status.isSuccess()) {
emit(DownloadState.Error("خطا در دانلود فایل (کد ${response.status.value})"))
return@execute
}
val oldFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName) val channel: ByteReadChannel = response.bodyAsChannel()
if (oldFile.exists()) oldFile.delete() val contentLength = response.contentLength() ?: -1L
val request = DownloadManager.Request(uri) destinationFile.outputStream().use { output ->
.setTitle("دریافت به‌روزرسانی") val buffer = ByteArray(16384)
.setDescription("نسخه جدید در حال دانلود است...") var bytesCopied = 0L
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) while (!channel.isClosedForRead) {
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName) val read = channel.readAvailable(buffer, 0, buffer.size)
.setAllowedOverMetered(true) if (read <= 0) break
.setAllowedOverRoaming(true) output.write(buffer, 0, read)
bytesCopied += read
val downloadId = downloadManager.enqueue(request) if (contentLength > 0) {
val progress = bytesCopied.toFloat() / contentLength.toFloat()
val onComplete = object : BroadcastReceiver() { emit(DownloadState.Progress(progress.coerceIn(0f, 1f)))
override fun onReceive(receiverContext: Context, intent: Intent) { } else {
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L) emit(DownloadState.Progress(0f))
if (id == downloadId) { }
installApk(fileName)
try {
receiverContext.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
} }
} }
emit(DownloadState.Progress(1f))
emit(DownloadState.Success(destinationFile))
} }
} } catch (e: Exception) {
emit(DownloadState.Error(e.localizedMessage ?: "خطای ارتباط با سرور"))
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, onComplete, filter, ContextCompat.RECEIVER_EXPORTED)
return downloadId
}
fun getDownloadProgress(downloadId: Long): Flow<Float> = flow {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var isDownloading = true
while (isDownloading) {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor != null && cursor.moveToFirst()) {
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesTotalIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (bytesDownloadedIndex != -1 && bytesTotalIndex != -1 && statusIndex != -1) {
val bytesDownloaded = cursor.getInt(bytesDownloadedIndex)
val bytesTotal = cursor.getInt(bytesTotalIndex)
val status = cursor.getInt(statusIndex)
if (status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED) {
isDownloading = false
}
if (bytesTotal > 0) {
emit(bytesDownloaded.toFloat() / bytesTotal.toFloat())
}
}
cursor.close()
} else {
isDownloading = false
cursor?.close()
}
if (isDownloading) delay(500.milliseconds)
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
private fun installApk(fileName: String) { fun installApk(apkFile: File) {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
if (!apkFile.exists()) return if (!apkFile.exists()) return
try { try {
@@ -179,6 +145,18 @@ class UpdateManager(private val context: Context) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
} }
context.startActivity(intent) context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(context, "خطا در اجرای فایل نصب: ${e.message}", Toast.LENGTH_LONG).show()
}
}
fun openInBrowser(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }