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:
@@ -10,6 +10,10 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
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
|
||||
android:allowBackup="true"
|
||||
@@ -24,6 +28,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".AeroTossService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.aerotoss.core.AeroTossManager
|
||||
import com.aerotoss.discovery.CompositeAndroidDiscovery
|
||||
@@ -25,14 +30,46 @@ class MainActivity : ComponentActivity() {
|
||||
handleAndroidPickResult(uri, applicationContext)
|
||||
}
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
if (permissions.values.all { it }) {
|
||||
startApp()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (manager != null) return
|
||||
|
||||
androidFilePickerLauncher = { 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 wifiDirectManager = WifiDirectManager(applicationContext)
|
||||
val discoveryManager = CompositeAndroidDiscovery(wifiDirectManager)
|
||||
@@ -51,5 +88,6 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
manager?.dispose()
|
||||
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.TransferProgress
|
||||
import com.aerotoss.transfer.TransferManager
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.io.File
|
||||
|
||||
@@ -18,14 +19,34 @@ class AeroTossManager(
|
||||
val serverPort: Int
|
||||
get() = transfer.getServerPort()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var autoScanJob: Job? = null
|
||||
|
||||
fun start(serverPort: Int) {
|
||||
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() {
|
||||
autoScanJob?.cancel()
|
||||
val port = transfer.getServerPort()
|
||||
discovery.stopDiscovery()
|
||||
discovery.startDiscovery(port)
|
||||
startAutoScan()
|
||||
}
|
||||
|
||||
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
|
||||
@@ -38,10 +59,13 @@ class AeroTossManager(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
autoScanJob?.cancel()
|
||||
discovery.stopDiscovery()
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
autoScanJob?.cancel()
|
||||
scope.cancel()
|
||||
discovery.dispose()
|
||||
transfer.dispose()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aerotoss.core.AeroTossManager
|
||||
import com.aerotoss.model.Device
|
||||
import com.aerotoss.model.TransferState
|
||||
import com.aerotoss.ui.components.DeviceCard
|
||||
import com.aerotoss.ui.components.TransferProgressBar
|
||||
|
||||
@@ -33,13 +34,9 @@ fun HomeScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
manager.rescan()
|
||||
},
|
||||
onClick = { manager.rescan() },
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
) {
|
||||
Text("Обновить", style = MaterialTheme.typography.labelLarge)
|
||||
@@ -62,8 +59,17 @@ fun HomeScreen(
|
||||
if (outgoingTransfer != null) {
|
||||
outgoingTransfer?.let { 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()) {
|
||||
@@ -72,19 +78,10 @@ fun HomeScreen(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Поиск устройств...",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text("Поиск устройств...", style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(
|
||||
onClick = { manager.rescan() }
|
||||
) {
|
||||
Text("Сканировать заново")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Убедитесь, что другое устройство\nтакже запустило AeroToss\nв одной Wi-Fi сети",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -93,27 +90,15 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"Найдено: ${devices.size}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(onClick = { manager.rescan() }) {
|
||||
Text("Обновить")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Найдено: ${devices.size}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyColumn {
|
||||
items(devices, key = { it.id }) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
onClick = { onSendClick(device) }
|
||||
)
|
||||
DeviceCard(device = device, onClick = { onSendClick(device) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,18 @@ fun SendScreen(
|
||||
when {
|
||||
outgoingTransfer?.state == TransferState.TRANSFERRING -> {
|
||||
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 -> {
|
||||
Text("Отправлено!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
|
||||
|
||||
Reference in New Issue
Block a user