add chat basics
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:ignore="RequestInstallPackagesPolicy" />
|
||||
|
||||
<application>
|
||||
<receiver
|
||||
android:name=".presentation.UpdateInstalledReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package ru.shadowsparky.updater.data
|
||||
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.updater.domain.UpdateInstaller
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@Factory
|
||||
class AndroidUpdateInstaller(
|
||||
private val app: Application
|
||||
) : UpdateInstaller {
|
||||
private val actionInstallStatus = "${app.packageName}.ACTION_INSTALL_STATUS"
|
||||
|
||||
override suspend fun hasInstallPermission(): Boolean =
|
||||
app.packageManager.canRequestPackageInstalls()
|
||||
|
||||
override suspend fun requestInstallPermission() {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${app.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
app.startActivity(intent)
|
||||
}
|
||||
|
||||
override suspend fun install(path: String) {
|
||||
val file = File(path)
|
||||
if (!file.exists()) {
|
||||
error("APK file not found at: $path")
|
||||
}
|
||||
|
||||
val packageInstaller = app.packageManager.packageInstaller
|
||||
val sessionId = createSession(packageInstaller)
|
||||
try {
|
||||
writeApkToSession(packageInstaller, sessionId, file)
|
||||
awaitInstallationResult(packageInstaller, sessionId)
|
||||
} catch (e: Exception) {
|
||||
runCatching { packageInstaller.abandonSession(sessionId) }
|
||||
throw e
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSession(packageInstaller: PackageInstaller): Int {
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
return packageInstaller.createSession(params)
|
||||
}
|
||||
|
||||
private fun writeApkToSession(packageInstaller: PackageInstaller, sessionId: Int, file: File) {
|
||||
packageInstaller.openSession(sessionId).use { session ->
|
||||
session.openWrite("update_stream", 0, file.length()).use { outputStream ->
|
||||
FileInputStream(file).use { inputStream ->
|
||||
val buffer = ByteArray(65536)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
}
|
||||
session.fsync(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitInstallationResult(packageInstaller: PackageInstaller, sessionId: Int) {
|
||||
suspendCancellableCoroutine<Unit> { continuation ->
|
||||
val receiver = createStatusReceiver(continuation)
|
||||
registerStatusReceiver(receiver)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
runCatching { app.unregisterReceiver(receiver) }
|
||||
runCatching { packageInstaller.abandonSession(sessionId) }
|
||||
}
|
||||
|
||||
val intent = Intent(actionInstallStatus)
|
||||
.setPackage(app.packageName)
|
||||
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
app,
|
||||
sessionId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
packageInstaller.openSession(sessionId).use { session ->
|
||||
session.commit(pendingIntent.intentSender)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createStatusReceiver(
|
||||
continuation: CancellableContinuation<Unit>
|
||||
): BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val userAction = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
|
||||
userAction?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
app.unregisterReceiver(this)
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
else -> {
|
||||
app.unregisterReceiver(this)
|
||||
continuation.resumeWithException(IllegalStateException("Installation failed ($status): $message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerStatusReceiver(receiver: BroadcastReceiver) {
|
||||
ContextCompat.registerReceiver(
|
||||
app,
|
||||
receiver,
|
||||
IntentFilter(actionInstallStatus),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package ru.shadowsparky.updater.data
|
||||
|
||||
import android.app.Application
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.prepareGet
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.readAvailable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.domain.DispatcherProvider
|
||||
import ru.shadowsparky.updater.domain.DownloadProgress
|
||||
import ru.shadowsparky.updater.domain.UpdateDownloader
|
||||
import java.io.File
|
||||
|
||||
@Factory
|
||||
class KtorUpdateDownloader(
|
||||
private val httpClient: HttpClient,
|
||||
app: Application,
|
||||
private val dispatcherProvider: DispatcherProvider
|
||||
) : UpdateDownloader {
|
||||
private val destinationPath = "${app.cacheDir.absolutePath}/update"
|
||||
|
||||
override fun download(url: String): Flow<DownloadProgress> = flow {
|
||||
try {
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
val contentLength = response.contentLength() ?: -1L
|
||||
val channel: ByteReadChannel = response.bodyAsChannel()
|
||||
val directory = File(destinationPath)
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
val fileName = "update.apk"
|
||||
val fullPath = "$destinationPath/$fileName"
|
||||
val file = File(fullPath)
|
||||
|
||||
if (file.exists()) file.delete()
|
||||
|
||||
val bufferSize = 8192
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var downloadedBytes = 0L
|
||||
|
||||
file.outputStream().use { outputStream ->
|
||||
while (!channel.isClosedForRead) {
|
||||
val read = channel.readAvailable(buffer, 0, buffer.size)
|
||||
if (read <= 0) break
|
||||
|
||||
outputStream.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
|
||||
if (contentLength > 0) {
|
||||
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
|
||||
emit(
|
||||
DownloadProgress.Active(
|
||||
progress = progress,
|
||||
downloadedBytes = downloadedBytes,
|
||||
totalBytes = contentLength
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalFile = File("$destinationPath/update.apk")
|
||||
emit(DownloadProgress.Success(path = finalFile.absolutePath))
|
||||
} catch (e: Exception) {
|
||||
emit(DownloadProgress.Error(e))
|
||||
}
|
||||
}.flowOn(dispatcherProvider.io)
|
||||
|
||||
override suspend fun clean(): Unit = withContext(dispatcherProvider.io) {
|
||||
File(destinationPath).deleteRecursively()
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package ru.shadowsparky.updater.presentation
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.updater.domain.UpdateDownloader
|
||||
|
||||
class UpdateInstalledReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(p0: Context?, p1: Intent?) {
|
||||
if (p1?.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
koin.getOrNull<UpdateDownloader>()?.let { downloader ->
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
val async = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
downloader.clean()
|
||||
} finally {
|
||||
async.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="updater_isolated_cache" path="update/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user