AeroToss: авто-обновление + foreground service + кнопка отмены

1. Авто-обновление:
- Discovery пересканируется каждые 5 секунд автоматически
- Устройства снова появляются после потери связи

2. Foreground service (Android):
- AeroTossService: уведомление + wake lock
- Разрешения: FOREGROUND_SERVICE, POST_NOTIFICATIONS, WAKE_LOCK
- Приложение работает в фоне, передача не останавливается

3. Кнопка отмены:
- HomeScreen: кнопка 'Отменить отправку' при передаче
- SendScreen: кнопка 'Отменить отправку' с отменой передачи

4. Разрешения:
- Запрос POST_NOTIFICATIONS при первом запуске (Android 13+)
- Запрос ACCESS_FINE_LOCATION
This commit is contained in:
2026-08-20 00:04:10 +03:00
parent a1b600a614
commit b6a0e75bab
6 changed files with 167 additions and 36 deletions
+8
View File
@@ -10,6 +10,10 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" /> android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -24,6 +28,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".AeroTossService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,64 @@
package com.aerotoss
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
class AeroTossService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val notification = Notification.Builder(this, CHANNEL_ID)
.setContentTitle("AeroToss")
.setContentText("Поиск устройств...")
.setSmallIcon(android.R.drawable.ic_menu_share)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
@Suppress("DEPRECATION")
startForeground(NOTIFICATION_ID, notification)
}
@Suppress("DEPRECATION")
val pm = getSystemService(POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AeroToss::DiscoveryLock")
wakeLock?.acquire(60 * 60 * 1000L)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onDestroy() {
wakeLock?.release()
super.onDestroy()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"AeroToss Discovery",
NotificationManager.IMPORTANCE_LOW
)
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_ID = "aerotoss_discovery"
const val NOTIFICATION_ID = 1
}
}
@@ -1,9 +1,14 @@
package com.aerotoss package com.aerotoss
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
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
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.aerotoss.core.AeroTossManager import com.aerotoss.core.AeroTossManager
import com.aerotoss.discovery.CompositeAndroidDiscovery import com.aerotoss.discovery.CompositeAndroidDiscovery
@@ -25,14 +30,46 @@ class MainActivity : ComponentActivity() {
handleAndroidPickResult(uri, applicationContext) handleAndroidPickResult(uri, applicationContext)
} }
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
if (permissions.values.all { it }) {
startApp()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (manager != null) return
androidFilePickerLauncher = { mimeTypes, _ -> androidFilePickerLauncher = { mimeTypes, _ ->
filePickerLauncher.launch(mimeTypes) filePickerLauncher.launch(mimeTypes)
} }
val neededPermissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.CHANGE_WIFI_MULTICAST_STATE,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val notGranted = neededPermissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isNotEmpty()) {
permissionLauncher.launch(notGranted.toTypedArray())
} else {
startApp()
}
}
private fun startApp() {
if (manager != null) return
startForegroundService(Intent(this, AeroTossService::class.java))
val transferManager = AndroidFileTransferManager(applicationContext) val transferManager = AndroidFileTransferManager(applicationContext)
val wifiDirectManager = WifiDirectManager(applicationContext) val wifiDirectManager = WifiDirectManager(applicationContext)
val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager) val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager)
@@ -51,5 +88,6 @@ class MainActivity : ComponentActivity() {
super.onDestroy() super.onDestroy()
manager?.dispose() manager?.dispose()
manager = null manager = null
stopService(Intent(this, AeroTossService::class.java))
} }
} }
@@ -4,6 +4,7 @@ import com.aerotoss.discovery.DiscoveryManager
import com.aerotoss.model.Device import com.aerotoss.model.Device
import com.aerotoss.model.TransferProgress import com.aerotoss.model.TransferProgress
import com.aerotoss.transfer.TransferManager import com.aerotoss.transfer.TransferManager
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import java.io.File import java.io.File
@@ -18,14 +19,34 @@ class AeroTossManager(
val serverPort: Int val serverPort: Int
get() = transfer.getServerPort() get() = transfer.getServerPort()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var autoScanJob: Job? = null
fun start(serverPort: Int) { fun start(serverPort: Int) {
discovery.startDiscovery(serverPort) discovery.startDiscovery(serverPort)
startAutoScan()
}
private fun startAutoScan() {
autoScanJob?.cancel()
autoScanJob = scope.launch {
while (isActive) {
delay(5_000)
val port = transfer.getServerPort()
if (port > 0) {
discovery.stopDiscovery()
discovery.startDiscovery(port)
}
}
}
} }
fun rescan() { fun rescan() {
autoScanJob?.cancel()
val port = transfer.getServerPort() val port = transfer.getServerPort()
discovery.stopDiscovery() discovery.stopDiscovery()
discovery.startDiscovery(port) discovery.startDiscovery(port)
startAutoScan()
} }
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> { suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
@@ -38,10 +59,13 @@ class AeroTossManager(
} }
fun stop() { fun stop() {
autoScanJob?.cancel()
discovery.stopDiscovery() discovery.stopDiscovery()
} }
fun dispose() { fun dispose() {
autoScanJob?.cancel()
scope.cancel()
discovery.dispose() discovery.dispose()
transfer.dispose() transfer.dispose()
} }
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.aerotoss.core.AeroTossManager import com.aerotoss.core.AeroTossManager
import com.aerotoss.model.Device import com.aerotoss.model.Device
import com.aerotoss.model.TransferState
import com.aerotoss.ui.components.DeviceCard import com.aerotoss.ui.components.DeviceCard
import com.aerotoss.ui.components.TransferProgressBar import com.aerotoss.ui.components.TransferProgressBar
@@ -33,13 +34,9 @@ fun HomeScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
Row( Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = { manager.rescan() },
manager.rescan()
},
containerColor = MaterialTheme.colorScheme.tertiaryContainer containerColor = MaterialTheme.colorScheme.tertiaryContainer
) { ) {
Text("Обновить", style = MaterialTheme.typography.labelLarge) Text("Обновить", style = MaterialTheme.typography.labelLarge)
@@ -62,8 +59,17 @@ fun HomeScreen(
if (outgoingTransfer != null) { if (outgoingTransfer != null) {
outgoingTransfer?.let { transfer -> outgoingTransfer?.let { transfer ->
TransferProgressBar(transfer) TransferProgressBar(transfer)
Spacer(modifier = Modifier.height(8.dp)) if (transfer.state == TransferState.TRANSFERRING) {
Spacer(modifier = Modifier.height(4.dp))
OutlinedButton(
onClick = { manager.cancelTransfer(transfer.id) },
modifier = Modifier.fillMaxWidth()
) {
Text("Отменить отправку")
}
}
} }
Spacer(modifier = Modifier.height(8.dp))
} }
if (devices.isEmpty()) { if (devices.isEmpty()) {
@@ -72,19 +78,10 @@ fun HomeScreen(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text("Поиск устройств...", style = MaterialTheme.typography.bodyLarge)
"Поиск устройств...",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
CircularProgressIndicator() CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { manager.rescan() }
) {
Text("Сканировать заново")
}
Spacer(modifier = Modifier.height(8.dp))
Text( Text(
"Убедитесь, что другое устройство\nтакже запустило AeroToss\nв одной Wi-Fi сети", "Убедитесь, что другое устройство\nтакже запустило AeroToss\nв одной Wi-Fi сети",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -93,27 +90,15 @@ fun HomeScreen(
} }
} }
} else { } else {
Row( Text(
modifier = Modifier.fillMaxWidth(), "Найдено: ${devices.size}",
horizontalArrangement = Arrangement.SpaceBetween, style = MaterialTheme.typography.titleSmall,
verticalAlignment = Alignment.CenterVertically color = MaterialTheme.colorScheme.onSurfaceVariant
) { )
Text(
"Найдено: ${devices.size}",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = { manager.rescan() }) {
Text("Обновить")
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyColumn { LazyColumn {
items(devices, key = { it.id }) { device -> items(devices, key = { it.id }) { device ->
DeviceCard( DeviceCard(device = device, onClick = { onSendClick(device) })
device = device,
onClick = { onSendClick(device) }
)
} }
} }
} }
@@ -71,6 +71,18 @@ fun SendScreen(
when { when {
outgoingTransfer?.state == TransferState.TRANSFERRING -> { outgoingTransfer?.state == TransferState.TRANSFERRING -> {
outgoingTransfer?.let { TransferProgressBar(it) } outgoingTransfer?.let { TransferProgressBar(it) }
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
outgoingTransfer?.let { manager.cancelTransfer(it.id) }
selectedFile = null
isSending = false
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Отменить отправку")
}
} }
outgoingTransfer?.state == TransferState.COMPLETED -> { outgoingTransfer?.state == TransferState.COMPLETED -> {
Text("Отправлено!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary) Text("Отправлено!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)