Own Android file transfers and core activity independently of UI
This commit is contained in:
1 parent
6e5f86b2b5
commit
51e7f821e9
3 files changed
+341
-4
No files matched your search
@@ -71,6 +71,7 @@ object CallController : TelecomCallManager.Listener {
|
||||
fun setActivityVisible(visible: Boolean) {
|
||||
checkMain()
|
||||
activityVisible = visible
|
||||
CoreActivity.visible(visible)
|
||||
syncCoreActivity()
|
||||
if (visible && answerWhenVisible != 0L) {
|
||||
val id = answerWhenVisible
|
||||
@@ -80,10 +81,7 @@ object CallController : TelecomCallManager.Listener {
|
||||
}
|
||||
|
||||
private fun syncCoreActivity() {
|
||||
val ongoing = _state.value?.ongoing == true
|
||||
val active = activityVisible || ongoing
|
||||
LogManager.addLog("INFO", "Call", "core activity=$active visible=$activityVisible call=$ongoing")
|
||||
if (coreReady && NativeLib.isReady()) NativeLib.setActive(active)
|
||||
CoreActivity.call(_state.value?.ongoing == true)
|
||||
}
|
||||
|
||||
fun start(channelId: String, peerNodeId: Long, peerName: String) {
|
||||
@@ -231,8 +229,10 @@ object CallController : TelecomCallManager.Listener {
|
||||
fun reset() {
|
||||
checkMain()
|
||||
coreReady = false
|
||||
CoreActivity.reset()
|
||||
end()
|
||||
_state.value = null
|
||||
CoreActivity.call(false)
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.os.Looper
|
||||
|
||||
/** Один расчёт активности ядра: завершение одного владельца не усыпляет остальных. */
|
||||
object CoreActivity {
|
||||
private var visible = false
|
||||
private var call = false
|
||||
private var transfer = false
|
||||
private var applied: Boolean? = null
|
||||
fun visible(value: Boolean) { visible = value; sync() }
|
||||
fun call(value: Boolean) { call = value; sync() }
|
||||
fun transfer(value: Boolean) { transfer = value; sync() }
|
||||
fun reset() { applied = null }
|
||||
fun sync() {
|
||||
check(Looper.myLooper() == Looper.getMainLooper())
|
||||
if (!NativeLib.isReady()) { applied = null; return }
|
||||
val active = visible || call || transfer
|
||||
if (applied == active) return
|
||||
applied = active
|
||||
LogManager.addLog("INFO", "Activity", "core active=$active visible=$visible call=$call transfer=$transfer")
|
||||
NativeLib.setActive(active)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.DocumentsContract
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.headless.HeadlessService
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
data class FileLocation(val group: Long, val server: Long, val owner: Long, val path: String)
|
||||
data class FileOperation(val token: Long, val location: FileLocation, val name: String = "", val index: Int = 1,
|
||||
val count: Int = 1, val done: Long = 0, val total: Long = 0, val stage: Int = R.string.files_preparing,
|
||||
val status: Int? = null, val active: Boolean = true, val cancelled: Boolean = false, val retryable: Boolean = true,
|
||||
val upload: Boolean = false, val uploaded: Int = 0, val skipped: Int = 0)
|
||||
data class FileConflict(val token: Long, val name: String, val decision: CompletableDeferred<Boolean?>)
|
||||
data class FileExport(val token: Long, val location: FileLocation, val name: String, val file: File, val action: Int)
|
||||
class FileStatusException(val status: Int) : IOException("member_files status=$status")
|
||||
|
||||
/** Владеет передачами независимо от экранов. Все состояния/ожидания изменяются только на main. */
|
||||
class MemberFilesRepository(context: Context) {
|
||||
companion object { const val OPEN = 0; const val SAVE = 1; const val SHARE = 2 }
|
||||
private val app = context.applicationContext
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private data class Pending(val location: FileLocation, val command: Int, val result: CompletableDeferred<MemberFilesProtocol.Reply>)
|
||||
private data class Transfer(val location: FileLocation, val result: CompletableDeferred<MemberFilesProtocol.Progress>)
|
||||
private val pending = mutableMapOf<Long, Pending>()
|
||||
private val transfers = mutableMapOf<Long, Transfer>()
|
||||
private var task: Job? = null
|
||||
private var retry: (() -> Unit)? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private val _operation = MutableStateFlow<FileOperation?>(null)
|
||||
val operation: StateFlow<FileOperation?> = _operation
|
||||
private val _conflict = MutableStateFlow<FileConflict?>(null)
|
||||
val conflict: StateFlow<FileConflict?> = _conflict
|
||||
private val _export = MutableStateFlow<FileExport?>(null)
|
||||
val export: StateFlow<FileExport?> = _export
|
||||
private val _revision = MutableStateFlow(0L)
|
||||
val revision: StateFlow<Long> = _revision
|
||||
private fun log(level: String, message: String) = LogManager.addLog(level, "Files", message)
|
||||
private fun main() = check(Looper.myLooper() == Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
// URI для внешнего приложения сохраняется сутки; старые экспорты и брошенные источники очищаются при запуске.
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val root = File(app.cacheDir, "member_files")
|
||||
val cutoff = System.currentTimeMillis() - 24 * 60 * 60 * 1000
|
||||
root.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { file ->
|
||||
if (file.isDirectory) file.listFiles()?.forEach { remove(it) }
|
||||
remove(file)
|
||||
}
|
||||
} catch (e: Exception) { log("WARN", "Cache cleanup failed: $e") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun protect(active: Boolean) {
|
||||
if (active && wakeLock == null) {
|
||||
val manager = app.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:member_files").apply { acquire() }
|
||||
} else if (!active) {
|
||||
wakeLock?.let { if (it.isHeld) it.release() }; wakeLock = null
|
||||
}
|
||||
CoreActivity.transfer(active)
|
||||
}
|
||||
|
||||
fun onEvent(type: Int, bytes: ByteArray?) {
|
||||
main()
|
||||
if (type == 13) { coreStopped(); return }
|
||||
if (type !in 49..50 || bytes == null) return
|
||||
try {
|
||||
if (type == 49) {
|
||||
val r = MemberFilesProtocol.response(bytes); val p = pending[r.id] ?: return
|
||||
require(r.group == p.location.group && r.server == p.location.server && r.owner == p.location.owner &&
|
||||
r.command == p.command && r.path == p.location.path) { "request context mismatch id=${r.id}" }
|
||||
log("DEBUG", "response id=${r.id} command=${r.command} location=${p.location} status=${r.status}")
|
||||
p.result.complete(r)
|
||||
} else {
|
||||
val r = MemberFilesProtocol.progress(bytes); val p = transfers[r.id] ?: return
|
||||
require(r.group == p.location.group && r.server == p.location.server && r.owner == p.location.owner) {
|
||||
"transfer context mismatch id=${r.id}"
|
||||
}
|
||||
if (r.finished) {
|
||||
log("INFO", "transfer finished id=${r.id} location=${p.location} status=${r.status} bytes=${r.done}/${r.total}")
|
||||
p.result.complete(r)
|
||||
} else _operation.value?.let { publish(it.copy(done = r.done, total = r.total, stage = R.string.files_transferring)) }
|
||||
}
|
||||
} catch (e: Exception) { log("ERROR", "Rejected event type=$type bytes=${bytes.size}: $e") }
|
||||
}
|
||||
|
||||
suspend fun request(location: FileLocation, command: Int, target: String = "", cursor: Long = 0,
|
||||
version: ByteArray = ByteArray(16)): MemberFilesProtocol.Reply {
|
||||
main()
|
||||
val id = MemberFilesProtocol.nextId(); val result = CompletableDeferred<MemberFilesProtocol.Reply>()
|
||||
pending[id] = Pending(location, command, result)
|
||||
var finished = false
|
||||
try {
|
||||
log("DEBUG", "request id=$id command=$command location=$location cursor=$cursor")
|
||||
val wire = MemberFilesProtocol.request(id, location.owner, command, location.path, target, cursor, version)
|
||||
if (!NativeLib.fileRequest(location.group, location.server, wire)) throw IOException("request enqueue failed id=$id")
|
||||
return withTimeout(60_000) { result.await() }.also { finished = true }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
log("WARN", "request timed out id=$id command=$command location=$location")
|
||||
throw FileStatusException(13)
|
||||
} finally {
|
||||
pending.remove(id)
|
||||
if (!finished && NativeLib.isReady()) NativeLib.fileCancel(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun check(reply: MemberFilesProtocol.Reply): MemberFilesProtocol.Reply {
|
||||
if (reply.status != 0) throw FileStatusException(reply.status)
|
||||
return reply
|
||||
}
|
||||
|
||||
private suspend fun transfer(location: FileLocation, local: File, upload: Boolean, version: ByteArray) {
|
||||
val id = MemberFilesProtocol.nextId(); val result = CompletableDeferred<MemberFilesProtocol.Progress>()
|
||||
transfers[id] = Transfer(location, result)
|
||||
var queued = false
|
||||
try {
|
||||
queued = NativeLib.fileTransfer(id, location.group, location.server, location.owner, location.path, local.path, upload, version)
|
||||
if (!queued) throw IOException("transfer enqueue failed id=$id")
|
||||
val r = result.await()
|
||||
if (r.status != 0) throw FileStatusException(r.status)
|
||||
} finally {
|
||||
// Временный источник/приёмник освобождаем только после закрытия native handle.
|
||||
if (queued && !result.isCompleted) {
|
||||
if (NativeLib.isReady()) NativeLib.fileCancel(id)
|
||||
withContext(NonCancellable) { runCatching { result.await() } }
|
||||
}
|
||||
transfers.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun publish(operation: FileOperation) {
|
||||
val previous = _operation.value
|
||||
_operation.value = operation
|
||||
// Уведомление обновляется по процентам; UI получает каждый прогресс без спама в логах.
|
||||
fun percent(op: FileOperation?) = if (op == null || op.total == 0L) -1 else (op.done.toDouble() * 100 / op.total).toInt()
|
||||
if (previous?.name != operation.name || previous?.stage != operation.stage || previous?.active != operation.active ||
|
||||
percent(previous) != percent(operation)) HeadlessService.refreshFiles()
|
||||
}
|
||||
|
||||
private fun launch(location: FileLocation, count: Int, retryable: Boolean = true, work: suspend (Long) -> Unit) {
|
||||
main()
|
||||
if (task?.isCompleted == false || _export.value != null) {
|
||||
log("WARN", "operation rejected while busy location=$location"); return
|
||||
}
|
||||
val token = MemberFilesProtocol.nextId()
|
||||
retry = if (retryable) ({ launch(location, count, work = work) }) else null
|
||||
publish(FileOperation(token, location, count = count, retryable = retryable))
|
||||
task = scope.launch {
|
||||
try {
|
||||
protect(true)
|
||||
log("INFO", "operation started token=$token location=$location count=$count")
|
||||
work(token)
|
||||
publish(requireNotNull(_operation.value).copy(active = false, stage = R.string.files_completed))
|
||||
_revision.value++
|
||||
} catch (e: CancellationException) {
|
||||
log("INFO", "operation cancelled token=$token location=$location")
|
||||
_operation.value?.let { publish(it.copy(active = false, cancelled = true, stage = R.string.files_cancelled)) }
|
||||
_revision.value++
|
||||
} catch (e: Exception) {
|
||||
log("ERROR", "operation failed token=$token location=$location: $e")
|
||||
_operation.value?.let { publish(it.copy(active = false, status = (e as? FileStatusException)?.status ?:
|
||||
if (e is IllegalArgumentException) 3 else 9,
|
||||
stage = R.string.files_failed)) }
|
||||
_revision.value++
|
||||
} finally {
|
||||
_conflict.value = null
|
||||
protect(false)
|
||||
log("DEBUG", "operation released token=$token wake=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(location: FileLocation, uris: List<Uri>) {
|
||||
if (uris.isEmpty()) return
|
||||
val sources = uris.distinct()
|
||||
launch(location, sources.size) { token ->
|
||||
require(location.owner == NativeLib.getMyNodeId() && !MemberFilesProtocol.servicePath(location.path))
|
||||
var uploaded = 0
|
||||
var skipped = 0
|
||||
for ((index, uri) in sources.withIndex()) {
|
||||
retry = { upload(location, sources.drop(index)) }
|
||||
protect(true)
|
||||
var source: File? = null
|
||||
try {
|
||||
val name = withContext(Dispatchers.IO) {
|
||||
app.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||
if (it.moveToFirst()) it.getString(0) else null
|
||||
} ?: throw IOException("document has no display name uri=$uri")
|
||||
}
|
||||
val remote = location.copy(path = MemberFilesProtocol.child(location.path, name))
|
||||
require(!MemberFilesProtocol.servicePath(remote.path)) { "reserved avatar directory" }
|
||||
publish(FileOperation(token, location, name, index + 1, sources.size, upload = true, uploaded = uploaded, skipped = skipped))
|
||||
val stat = request(remote, MemberFilesProtocol.STAT)
|
||||
var version = ByteArray(16)
|
||||
if (stat.status == 0) {
|
||||
if (stat.type != MemberFilesProtocol.FILE) throw FileStatusException(5)
|
||||
val answer = CompletableDeferred<Boolean?>()
|
||||
_conflict.value = FileConflict(token, name, answer)
|
||||
publish(requireNotNull(_operation.value).copy(stage = R.string.files_replace_title))
|
||||
protect(false)
|
||||
val replace = answer.await()
|
||||
currentCoroutineContext().ensureActive()
|
||||
when (replace) {
|
||||
null -> throw CancellationException("queue cancelled")
|
||||
false -> {
|
||||
skipped++; publish(requireNotNull(_operation.value).copy(skipped = skipped))
|
||||
retry = { upload(location, sources.drop(index + 1)) }
|
||||
log("INFO", "upload skipped token=$token path=${remote.path}"); continue
|
||||
}
|
||||
true -> version = stat.version
|
||||
}
|
||||
protect(true)
|
||||
_conflict.value = null
|
||||
} else if (stat.status != MemberFilesProtocol.NOT_FOUND) throw FileStatusException(stat.status)
|
||||
// Владение фиксируется внутри IO до возврата на main, в том числе при отмене.
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = File.createTempFile("upload-", ".tmp", exportDirectory()); source = file
|
||||
app.contentResolver.openInputStream(uri)?.use { input -> file.outputStream().use { output ->
|
||||
val bytes = ByteArray(64 * 1024)
|
||||
while (true) { ensureActive(); val size = input.read(bytes); if (size < 0) break; output.write(bytes, 0, size) }
|
||||
} } ?: throw IOException("document cannot be read uri=$uri")
|
||||
}
|
||||
transfer(remote, requireNotNull(source), true, version)
|
||||
uploaded++; publish(requireNotNull(_operation.value).copy(uploaded = uploaded))
|
||||
retry = { upload(location, sources.drop(index + 1)) }
|
||||
_revision.value++
|
||||
} finally {
|
||||
_conflict.value = null
|
||||
withContext(NonCancellable + Dispatchers.IO) { source?.let { remove(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportDirectory(): File {
|
||||
val directory = File(app.cacheDir, "member_files")
|
||||
if (!directory.isDirectory && !directory.mkdirs()) throw IOException("cannot create file cache")
|
||||
return directory
|
||||
}
|
||||
private fun remove(file: File) { if (file.exists() && !file.delete()) log("WARN", "Cannot remove temporary file=$file") }
|
||||
|
||||
fun download(location: FileLocation, entry: MemberFilesProtocol.Entry, action: Int) {
|
||||
launch(location, 1) { token ->
|
||||
publish(FileOperation(token, location, entry.name))
|
||||
val remote = location.copy(path = MemberFilesProtocol.child(location.path, entry.name))
|
||||
var directory: File? = null
|
||||
var file: File? = null
|
||||
var retained = false
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dir = File(exportDirectory(), token.toString()); directory = dir
|
||||
if (!dir.mkdir()) throw IOException("cannot create export directory")
|
||||
file = File(dir, entry.name)
|
||||
}
|
||||
transfer(remote, requireNotNull(file), false, entry.version)
|
||||
_export.value = FileExport(token, location, entry.name, requireNotNull(file), action); retained = true
|
||||
} finally {
|
||||
if (!retained) withContext(NonCancellable + Dispatchers.IO) { file?.let { remove(it) }; directory?.let { remove(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeExport(token: Long) { if (_export.value?.token == token) _export.value = null }
|
||||
fun decideReplace(token: Long, replace: Boolean?) {
|
||||
_conflict.value?.takeIf { it.token == token }?.decision?.complete(replace)
|
||||
}
|
||||
fun cancel() {
|
||||
main()
|
||||
log("INFO", "cancel requested token=${_operation.value?.token}")
|
||||
task?.cancel()
|
||||
}
|
||||
fun retry() { if (_operation.value?.active == false) retry?.invoke() }
|
||||
fun coreStopped() {
|
||||
main(); cancel()
|
||||
val error = CancellationException("core stopped")
|
||||
pending.values.toList().forEach { it.result.completeExceptionally(error) }
|
||||
transfers.values.toList().forEach { it.result.completeExceptionally(error) }
|
||||
CoreActivity.reset()
|
||||
}
|
||||
fun dismiss() { if (_operation.value?.active == false) _operation.value = null; HeadlessService.refreshFiles() }
|
||||
|
||||
fun save(export: FileExport, uri: Uri) {
|
||||
launch(export.location, 1, retryable = false) { token ->
|
||||
publish(FileOperation(token, export.location, export.name, stage = R.string.files_saving, retryable = false))
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
app.contentResolver.openOutputStream(uri, "w")?.use { output -> export.file.inputStream().use { input ->
|
||||
val bytes = ByteArray(64 * 1024)
|
||||
while (true) { ensureActive(); val n = input.read(bytes); if (n < 0) break; output.write(bytes, 0, n) }
|
||||
} } ?: throw IOException("cannot open output document uri=$uri")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(NonCancellable + Dispatchers.IO) {
|
||||
try {
|
||||
if (!DocumentsContract.deleteDocument(app.contentResolver, uri)) log("WARN", "Cannot delete incomplete document uri=$uri")
|
||||
} catch (cleanup: Exception) { log("WARN", "Incomplete document cleanup failed uri=$uri: $cleanup") }
|
||||
}
|
||||
throw e
|
||||
}
|
||||
log("INFO", "document saved file=${export.file} uri=$uri")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user