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