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" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<service <service
android:name=".AeroTossService" android:name=".AeroTossService"
@@ -3,8 +3,10 @@ package com.aerotoss
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -20,6 +22,7 @@ import com.aerotoss.wifi.WifiDirectManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var manager: AeroTossManager? = null 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() { private fun startApp() {
if (manager != null) return if (manager != null) {
handleShareIntent(intent)
return
}
startForegroundService(Intent(this, AeroTossService::class.java)) startForegroundService(Intent(this, AeroTossService::class.java))
@@ -79,6 +137,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { lifecycleScope.launch {
val port = withContext(Dispatchers.IO) { transferManager.startServer(0) } val port = withContext(Dispatchers.IO) { transferManager.startServer(0) }
mgr.start(port) mgr.start(port)
handleShareIntent(intent)
} }
setContent { App(mgr) } setContent { App(mgr) }
@@ -242,6 +242,8 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
val sha256 = MessageDigest.getInstance("SHA-256") val sha256 = MessageDigest.getInstance("SHA-256")
var bytesSent = 0L var bytesSent = 0L
var lastProgressTime = System.currentTimeMillis()
var lastProgressBytes = 0L
val buffer = ByteArray(65536) val buffer = ByteArray(65536)
file.inputStream().use { fis -> file.inputStream().use { fis ->
while (true) { while (true) {
@@ -250,13 +252,20 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
output.write(buffer, 0, read) output.write(buffer, 0, read)
sha256.update(buffer, 0, read) sha256.update(buffer, 0, read)
bytesSent += read bytesSent += read
val now = System.currentTimeMillis()
if (now - lastProgressTime >= 500) {
updateOutgoingById(requestId, TransferProgress( updateOutgoingById(requestId, TransferProgress(
id = requestId, id = requestId,
request = request, request = request,
bytesTransferred = bytesSent, bytesTransferred = bytesSent,
totalBytes = file.length(), totalBytes = file.length(),
lastUpdateTimeMs = now,
lastBytesTransferred = lastProgressBytes,
state = TransferState.TRANSFERRING state = TransferState.TRANSFERRING
)) ))
lastProgressTime = now
lastProgressBytes = bytesSent
}
} }
} }
output.flush() output.flush()
@@ -1,8 +1,10 @@
package com.aerotoss.core package com.aerotoss.core
import com.aerotoss.discovery.DiscoveryManager import com.aerotoss.discovery.DiscoveryManager
import com.aerotoss.discovery.TcpDiscovery
import com.aerotoss.model.Device import com.aerotoss.model.Device
import com.aerotoss.model.TransferProgress import com.aerotoss.model.TransferProgress
import com.aerotoss.model.TransferState
import com.aerotoss.transfer.TransferManager import com.aerotoss.transfer.TransferManager
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.io.File import java.io.File
@@ -18,6 +20,9 @@ class AeroTossManager(
val serverPort: Int val serverPort: Int
get() = transfer.getServerPort() get() = transfer.getServerPort()
var sharedFiles: List<File> = emptyList()
private set
fun start(serverPort: Int) { fun start(serverPort: Int) {
discovery.startDiscovery(serverPort) discovery.startDiscovery(serverPort)
} }
@@ -28,13 +33,27 @@ class AeroTossManager(
discovery.startDiscovery(port) 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> { suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" } 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) return transfer.sendFile(file, target.hostAddress, target.port)
} }
fun cancelTransfer(requestId: String) { fun cancelTransfer(requestId: String) {
transfer.cancelTransfer(requestId) transfer.cancelTransfer(requestId)
setTransferring(false)
} }
fun stop() { fun stop() {
@@ -34,6 +34,9 @@ class TcpDiscovery(
private val discoveredDevices = ConcurrentHashMap<String, Device>() private val discoveredDevices = ConcurrentHashMap<String, Device>()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Volatile var isActive = true
@Volatile var isTransferring = false
override fun startDiscovery(servicePort: Int) { override fun startDiscovery(servicePort: Int) {
if (serverThread != null) return if (serverThread != null) return
@@ -69,7 +72,8 @@ class TcpDiscovery(
var lastBroadcast = System.currentTimeMillis() var lastBroadcast = System.currentTimeMillis()
while (!Thread.currentThread().isInterrupted) { while (!Thread.currentThread().isInterrupted) {
val now = System.currentTimeMillis() 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) broadcast(socket, servicePort)
lastBroadcast = now lastBroadcast = now
} }
@@ -15,6 +15,8 @@ data class TransferProgress(
val totalBytes: Long, val totalBytes: Long,
val state: TransferState, val state: TransferState,
val startTimeMs: Long = System.currentTimeMillis(), val startTimeMs: Long = System.currentTimeMillis(),
val lastUpdateTimeMs: Long = System.currentTimeMillis(),
val lastBytesTransferred: Long = 0,
val error: String? = null val error: String? = null
) { ) {
val progress: Float val progress: Float
@@ -25,11 +27,11 @@ data class TransferProgress(
val speed: Long val speed: Long
get() { get() {
if (state != TransferState.TRANSFERRING || bytesTransferred == 0L) return 0L if (state != TransferState.TRANSFERRING) return 0L
val elapsed = (System.currentTimeMillis() - startTimeMs).coerceAtLeast(1) val now = System.currentTimeMillis()
return bytesTransferred * 1000 / elapsed 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 currentScreen by remember { mutableStateOf(Screen.HOME) }
var selectedDevice by remember { mutableStateOf<com.aerotoss.model.Device?>(null) } 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) { when (currentScreen) {
Screen.HOME -> HomeScreen( Screen.HOME -> HomeScreen(
manager = manager, manager = manager,
@@ -23,6 +23,7 @@ fun HomeScreen(
) { ) {
val devices by manager.devices.collectAsState(initial = emptyList()) val devices by manager.devices.collectAsState(initial = emptyList())
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null) val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
val incomingTransfer by manager.incomingTransfers.collectAsState(initial = null)
Scaffold( Scaffold(
topBar = { topBar = {
@@ -56,8 +57,33 @@ fun HomeScreen(
.padding(padding) .padding(padding)
.padding(16.dp) .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) { if (outgoingTransfer != null) {
outgoingTransfer?.let { transfer -> 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) TransferProgressBar(transfer)
if (transfer.state == TransferState.TRANSFERRING) { if (transfer.state == TransferState.TRANSFERRING) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@@ -65,12 +91,14 @@ fun HomeScreen(
onClick = { manager.cancelTransfer(transfer.id) }, onClick = { manager.cancelTransfer(transfer.id) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Отменить отправку") Text("Отменить")
}
} }
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
}
if (devices.isEmpty()) { if (devices.isEmpty()) {
Box( Box(
@@ -240,6 +240,8 @@ class DesktopFileTransferManager : TransferManager {
val sha256 = MessageDigest.getInstance("SHA-256") val sha256 = MessageDigest.getInstance("SHA-256")
var bytesSent = 0L var bytesSent = 0L
var lastProgressTime = System.currentTimeMillis()
var lastProgressBytes = 0L
val buffer = ByteArray(65536) val buffer = ByteArray(65536)
file.inputStream().use { fis -> file.inputStream().use { fis ->
while (true) { while (true) {
@@ -248,11 +250,18 @@ class DesktopFileTransferManager : TransferManager {
output.write(buffer, 0, read) output.write(buffer, 0, read)
sha256.update(buffer, 0, read) sha256.update(buffer, 0, read)
bytesSent += read bytesSent += read
val now = System.currentTimeMillis()
if (now - lastProgressTime >= 500) {
stateFlow.value = stateFlow.value.copy( stateFlow.value = stateFlow.value.copy(
bytesTransferred = bytesSent, bytesTransferred = bytesSent,
lastUpdateTimeMs = now,
lastBytesTransferred = lastProgressBytes,
state = TransferState.TRANSFERRING state = TransferState.TRANSFERRING
) )
updateOutgoing(stateFlow.value) updateOutgoing(stateFlow.value)
lastProgressTime = now
lastProgressBytes = bytesSent
}
} }
} }
output.flush() output.flush()