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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user