AeroToss: скорость, входящая передача, share intent, ленивый фон

1. Скорость передачи:
- TransferProgress: lastUpdateTimeMs + lastBytesTransferred для расчёта
- Прогресс обновляется каждые 500мс вместо каждого чанка
- Скорость = (bytesDelta * 1000) / timeDelta — реальная скорость

2. Входящая передача:
- HomeScreen показывает статус входящей передачи
- Отдельная карточка для исходящих и входящих передач
- Кнопка отмены для исходящих передач

3. Android share intent:
- ACTION_SEND / ACTION_SEND_MULTIPLE в AndroidManifest
- При отправке файла из проводника появляется AeroToss
- Файлы копируются в temp директорию и отправляются

4. Ленивая работа в фоне:
- isActive / isTransferring флаги в TcpDiscovery
- Интервал broadcast: активен=3с, передача=1с, фон=15с
- Экономия батареи при простое
This commit is contained in:
2026-08-20 00:40:00 +03:00
parent ce59cc5e1b
commit 0083a0fc66
9 changed files with 178 additions and 31 deletions
+10
View File
@@ -27,6 +27,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<service
android:name=".AeroTossService"
@@ -3,8 +3,10 @@ package com.aerotoss
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +22,7 @@ import com.aerotoss.wifi.WifiDirectManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null
@@ -65,8 +68,63 @@ class MainActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleShareIntent(intent)
}
private fun handleShareIntent(intent: Intent?) {
if (intent?.action != Intent.ACTION_SEND && intent?.action != Intent.ACTION_SEND_MULTIPLE) return
val uris = mutableListOf<Uri>()
when (intent.action) {
Intent.ACTION_SEND -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.add(it) }
}
Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let { uris.addAll(it) }
}
}
if (uris.isNotEmpty()) {
val files = uris.mapNotNull { uri ->
copyUriToTempFile(uri)
}
manager?.setSharedFiles(files)
}
}
private fun copyUriToTempFile(uri: Uri): File? {
return try {
val fileName = getFileName(uri) ?: "shared_file"
val tempFile = File(cacheDir, "share_${System.currentTimeMillis()}_$fileName")
contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output -> input.copyTo(output) }
}
tempFile
} catch (_: Exception) {
null
}
}
private fun getFileName(uri: Uri): String? {
return try {
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex >= 0) {
cursor.getString(nameIndex)
} else null
}
} catch (_: Exception) {
uri.lastPathSegment
}
}
private fun startApp() {
if (manager != null) return
if (manager != null) {
handleShareIntent(intent)
return
}
startForegroundService(Intent(this, AeroTossService::class.java))
@@ -79,6 +137,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch {
val port = withContext(Dispatchers.IO) { transferManager.startServer(0) }
mgr.start(port)
handleShareIntent(intent)
}
setContent { App(mgr) }
@@ -242,6 +242,8 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
val sha256 = MessageDigest.getInstance("SHA-256")
var bytesSent = 0L
var lastProgressTime = System.currentTimeMillis()
var lastProgressBytes = 0L
val buffer = ByteArray(65536)
file.inputStream().use { fis ->
while (true) {
@@ -250,13 +252,20 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
output.write(buffer, 0, read)
sha256.update(buffer, 0, read)
bytesSent += read
val now = System.currentTimeMillis()
if (now - lastProgressTime >= 500) {
updateOutgoingById(requestId, TransferProgress(
id = requestId,
request = request,
bytesTransferred = bytesSent,
totalBytes = file.length(),
lastUpdateTimeMs = now,
lastBytesTransferred = lastProgressBytes,
state = TransferState.TRANSFERRING
))
lastProgressTime = now
lastProgressBytes = bytesSent
}
}
}
output.flush()
@@ -1,8 +1,10 @@
package com.aerotoss.core
import com.aerotoss.discovery.DiscoveryManager
import com.aerotoss.discovery.TcpDiscovery
import com.aerotoss.model.Device
import com.aerotoss.model.TransferProgress
import com.aerotoss.model.TransferState
import com.aerotoss.transfer.TransferManager
import kotlinx.coroutines.flow.Flow
import java.io.File
@@ -18,6 +20,9 @@ class AeroTossManager(
val serverPort: Int
get() = transfer.getServerPort()
var sharedFiles: List<File> = emptyList()
private set
fun start(serverPort: Int) {
discovery.startDiscovery(serverPort)
}
@@ -28,13 +33,27 @@ class AeroTossManager(
discovery.startDiscovery(port)
}
fun setSharedFiles(files: List<File>) {
sharedFiles = files
}
fun setActive(active: Boolean) {
(discovery as? TcpDiscovery)?.isActive = active
}
fun setTransferring(transferring: Boolean) {
(discovery as? TcpDiscovery)?.isTransferring = transferring
}
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
setTransferring(true)
return transfer.sendFile(file, target.hostAddress, target.port)
}
fun cancelTransfer(requestId: String) {
transfer.cancelTransfer(requestId)
setTransferring(false)
}
fun stop() {
@@ -34,6 +34,9 @@ class TcpDiscovery(
private val discoveredDevices = ConcurrentHashMap<String, Device>()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Volatile var isActive = true
@Volatile var isTransferring = false
override fun startDiscovery(servicePort: Int) {
if (serverThread != null) return
@@ -69,7 +72,8 @@ class TcpDiscovery(
var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) {
val now = System.currentTimeMillis()
if (now - lastBroadcast > 1_000) {
val interval = if (isTransferring) 1_000L else if (isActive) 3_000L else 15_000L
if (now - lastBroadcast > interval) {
broadcast(socket, servicePort)
lastBroadcast = now
}
@@ -15,6 +15,8 @@ data class TransferProgress(
val totalBytes: Long,
val state: TransferState,
val startTimeMs: Long = System.currentTimeMillis(),
val lastUpdateTimeMs: Long = System.currentTimeMillis(),
val lastBytesTransferred: Long = 0,
val error: String? = null
) {
val progress: Float
@@ -25,11 +27,11 @@ data class TransferProgress(
val speed: Long
get() {
if (state != TransferState.TRANSFERRING || bytesTransferred == 0L) return 0L
val elapsed = (System.currentTimeMillis() - startTimeMs).coerceAtLeast(1)
return bytesTransferred * 1000 / elapsed
if (state != TransferState.TRANSFERRING) return 0L
val now = System.currentTimeMillis()
val timeDelta = (now - lastUpdateTimeMs).coerceAtLeast(1)
val bytesDelta = bytesTransferred - lastBytesTransferred
if (bytesDelta <= 0) return 0L
return bytesDelta * 1000 / timeDelta
}
val elapsedMs: Long
get() = System.currentTimeMillis() - startTimeMs
}
@@ -11,6 +11,13 @@ fun AppNavigation(manager: AeroTossManager) {
var currentScreen by remember { mutableStateOf(Screen.HOME) }
var selectedDevice by remember { mutableStateOf<com.aerotoss.model.Device?>(null) }
// Auto-navigate to send screen if shared files exist
LaunchedEffect(manager.sharedFiles) {
if (manager.sharedFiles.isNotEmpty() && currentScreen == Screen.HOME) {
currentScreen = Screen.SEND
}
}
when (currentScreen) {
Screen.HOME -> HomeScreen(
manager = manager,
@@ -23,6 +23,7 @@ fun HomeScreen(
) {
val devices by manager.devices.collectAsState(initial = emptyList())
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
val incomingTransfer by manager.incomingTransfers.collectAsState(initial = null)
Scaffold(
topBar = {
@@ -56,8 +57,33 @@ fun HomeScreen(
.padding(padding)
.padding(16.dp)
) {
if (incomingTransfer != null) {
incomingTransfer?.let { transfer ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("Входящая передача:", style = MaterialTheme.typography.titleSmall)
TransferProgressBar(transfer)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
if (outgoingTransfer != null) {
outgoingTransfer?.let { transfer ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("Исходящая передача:", style = MaterialTheme.typography.titleSmall)
TransferProgressBar(transfer)
if (transfer.state == TransferState.TRANSFERRING) {
Spacer(modifier = Modifier.height(4.dp))
@@ -65,12 +91,14 @@ fun HomeScreen(
onClick = { manager.cancelTransfer(transfer.id) },
modifier = Modifier.fillMaxWidth()
) {
Text("Отменить отправку")
Text("Отменить")
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
if (devices.isEmpty()) {
Box(
@@ -240,6 +240,8 @@ class DesktopFileTransferManager : TransferManager {
val sha256 = MessageDigest.getInstance("SHA-256")
var bytesSent = 0L
var lastProgressTime = System.currentTimeMillis()
var lastProgressBytes = 0L
val buffer = ByteArray(65536)
file.inputStream().use { fis ->
while (true) {
@@ -248,11 +250,18 @@ class DesktopFileTransferManager : TransferManager {
output.write(buffer, 0, read)
sha256.update(buffer, 0, read)
bytesSent += read
val now = System.currentTimeMillis()
if (now - lastProgressTime >= 500) {
stateFlow.value = stateFlow.value.copy(
bytesTransferred = bytesSent,
lastUpdateTimeMs = now,
lastBytesTransferred = lastProgressBytes,
state = TransferState.TRANSFERRING
)
updateOutgoing(stateFlow.value)
lastProgressTime = now
lastProgressBytes = bytesSent
}
}
}
output.flush()