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

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

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,24 +217,41 @@ 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()
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
return@UpdateDialog
}
isDownloading = true isDownloading = true
downloadProgress = 0f
if (!info.isForceUpdate) {
updateInfo = null
}
scope.launch { scope.launch {
updateManager.getDownloadProgress(id).collect { progress -> val fileName = "MonthlyPaymentApp_v${info.versionName}.apk"
downloadProgress = progress updateManager.downloadApk(info.url, fileName).collect { state ->
if (progress >= 1f) { 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)
) )
if (downloadProgress > 0f) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { downloadProgress }, progress = { downloadProgress },
modifier = Modifier.fillMaxWidth().height(8.dp), modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant, 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) {
if (!context.packageManager.canRequestPackageInstalls()) {
Toast.makeText(context, "لطفاً اجازه نصب برنامه‌های ناشناخته را بدهید", Toast.LENGTH_LONG).show()
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
return -1L
}
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val uri = url.toUri()
val oldFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
if (oldFile.exists()) oldFile.delete()
val request = DownloadManager.Request(uri)
.setTitle("دریافت به‌روزرسانی")
.setDescription("نسخه جدید در حال دانلود است...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val downloadId = downloadManager.enqueue(request)
val onComplete = object : BroadcastReceiver() {
override fun onReceive(receiverContext: Context, intent: Intent) {
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
if (id == downloadId) {
installApk(fileName)
try { try {
receiverContext.unregisterReceiver(this) val destinationFile = File(
} catch (e: Exception) { context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
e.printStackTrace() fileName
} )
} if (destinationFile.exists()) {
} destinationFile.delete()
} }
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) client.prepareGet(url) {
ContextCompat.registerReceiver(context, onComplete, filter, ContextCompat.RECEIVER_EXPORTED) 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 ->
return downloadId if (!response.status.isSuccess()) {
emit(DownloadState.Error("خطا در دانلود فایل (کد ${response.status.value})"))
return@execute
} }
fun getDownloadProgress(downloadId: Long): Flow<Float> = flow { val channel: ByteReadChannel = response.bodyAsChannel()
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val contentLength = response.contentLength() ?: -1L
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) { destinationFile.outputStream().use { output ->
val bytesDownloaded = cursor.getInt(bytesDownloadedIndex) val buffer = ByteArray(16384)
val bytesTotal = cursor.getInt(bytesTotalIndex) var bytesCopied = 0L
val status = cursor.getInt(statusIndex) while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
output.write(buffer, 0, read)
bytesCopied += read
if (status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED) { if (contentLength > 0) {
isDownloading = false val progress = bytesCopied.toFloat() / contentLength.toFloat()
} emit(DownloadState.Progress(progress.coerceIn(0f, 1f)))
if (bytesTotal > 0) {
emit(bytesDownloaded.toFloat() / bytesTotal.toFloat())
}
}
cursor.close()
} else { } else {
isDownloading = false emit(DownloadState.Progress(0f))
cursor?.close()
} }
if (isDownloading) delay(500.milliseconds) }
}
emit(DownloadState.Progress(1f))
emit(DownloadState.Success(destinationFile))
}
} catch (e: Exception) {
emit(DownloadState.Error(e.localizedMessage ?: "خطای ارتباط با سرور"))
} }
}.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()
} }