AppUpdater
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package `in`.sminnovations.attendanceapp.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.google.firebase.firestore.ktx.firestore
|
||||
import com.google.firebase.ktx.Firebase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
data class UpdateInfo(
|
||||
val version: String = "",
|
||||
val url: String = "",
|
||||
val isNew: Boolean = false
|
||||
)
|
||||
|
||||
object UpdateChecker {
|
||||
private const val COLLECTION_NAME = "app_updates"
|
||||
private const val DOCUMENT_NAME = "latest_version"
|
||||
|
||||
suspend fun checkForUpdate(
|
||||
context: Context
|
||||
): Result<UpdateInfo> = runCatching {
|
||||
val currentVersion = getCurrentVersion(context)
|
||||
val updateInfo = getLatestVersion()
|
||||
|
||||
if (isNewerVersion(currentVersion, updateInfo.version)) {
|
||||
updateInfo.copy(isNew = true)
|
||||
} else {
|
||||
updateInfo.copy(isNew = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentVersion(context: Context): String {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
} catch (e: Exception) {
|
||||
throw UpdateCheckException("Failed to get current app version", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getLatestVersion(): UpdateInfo {
|
||||
return try {
|
||||
val document = Firebase.firestore
|
||||
.collection(COLLECTION_NAME)
|
||||
.document(DOCUMENT_NAME)
|
||||
.get()
|
||||
.await()
|
||||
|
||||
if (!document.exists()) {
|
||||
throw UpdateCheckException("Update document does not exist")
|
||||
}
|
||||
|
||||
UpdateInfo(
|
||||
version = document.getString("version") ?: throw UpdateCheckException("Version field missing"),
|
||||
url = document.getString("url") ?: throw UpdateCheckException("URL field missing")
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw UpdateCheckException("Failed to fetch update info", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNewerVersion(currentVersion: String, latestVersion: String): Boolean {
|
||||
return try {
|
||||
val current = currentVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val latest = latestVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
for (i in 0 until maxOf(current.size, latest.size)) {
|
||||
val currentNum = current.getOrNull(i) ?: 0
|
||||
val latestNum = latest.getOrNull(i) ?: 0
|
||||
|
||||
when {
|
||||
latestNum > currentNum -> return true
|
||||
latestNum < currentNum -> return false
|
||||
}
|
||||
}
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateCheckException(
|
||||
message: String,
|
||||
cause: Throwable? = null
|
||||
) : Exception(message, cause){
|
||||
|
||||
|
||||
}
|
||||
|
||||
class AppUpdater(private val context: Context) {
|
||||
sealed class UpdaterState {
|
||||
object Idle : UpdaterState()
|
||||
data class Downloading(val progress: Float) : UpdaterState()
|
||||
data class Success(val file: File) : UpdaterState()
|
||||
data class Error(val message: String) : UpdaterState()
|
||||
}
|
||||
|
||||
private val _updateState = MutableStateFlow<UpdaterState>(UpdaterState.Idle)
|
||||
val updateState = _updateState.asStateFlow()
|
||||
|
||||
fun downloadAndInstall(url: String) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val file = downloadApk(url) { progress ->
|
||||
_updateState.value = UpdaterState.Downloading(progress)
|
||||
}
|
||||
_updateState.value = UpdaterState.Success(file)
|
||||
installApk(file)
|
||||
} catch (e: Exception) {
|
||||
_updateState.value = UpdaterState.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadApk(url: String, onProgress: (Float) -> Unit): File {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val client = OkHttpClient.Builder()
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
// Verify content type
|
||||
val contentType = originalResponse.header("Content-Type")
|
||||
if (contentType?.contains("application/vnd.android.package-archive") != true &&
|
||||
contentType?.contains("application/octet-stream") != true) {
|
||||
throw IOException("Invalid content type: $contentType")
|
||||
}
|
||||
originalResponse.newBuilder()
|
||||
.body(originalResponse.body?.let { body ->
|
||||
ProgressResponseBody(body) { progress ->
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
val file = File(context.cacheDir, "update.apk")
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IOException("Failed to download update")
|
||||
|
||||
response.body?.let { body ->
|
||||
file.outputStream().use { fileOut ->
|
||||
body.byteStream().use { bodyIn ->
|
||||
bodyIn.copyTo(fileOut)
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("Empty response")
|
||||
}
|
||||
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(file: File) {
|
||||
try {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
|
||||
// Verify the file is actually an APK
|
||||
if (file.length() == 0L) {
|
||||
throw IOException("Downloaded file is empty")
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
// Verify that there's an app to handle the install intent
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
throw IOException("No app found to handle APK installation")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_updateState.value = UpdaterState.Error("Installation failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressResponseBody(
|
||||
private val responseBody: ResponseBody,
|
||||
private val onProgressUpdate: (Float) -> Unit
|
||||
) : ResponseBody() {
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
override fun contentType() = responseBody.contentType()
|
||||
override fun contentLength() = responseBody.contentLength()
|
||||
override fun source(): BufferedSource = bufferedSource
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(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 = if (contentLength() > 0) {
|
||||
totalBytesRead.toFloat() / contentLength()
|
||||
} else {
|
||||
-1f
|
||||
}
|
||||
onProgressUpdate(progress)
|
||||
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user