diff --git a/app/src/main/java/in/sminnovations/attendanceapp/main/AppUpdator.kt b/app/src/main/java/in/sminnovations/attendanceapp/main/AppUpdator.kt new file mode 100644 index 0000000..8351dfb --- /dev/null +++ b/app/src/main/java/in/sminnovations/attendanceapp/main/AppUpdator.kt @@ -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 = 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.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 + } + } + } +}