add chat basics

This commit is contained in:
2026-07-28 20:45:30 +03:00
parent 1f90bf67ab
commit 644bcf9895
72 changed files with 323 additions and 14 deletions
@@ -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>
@@ -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
)
}
}
@@ -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()
}
}
@@ -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>