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.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,9 +59,18 @@ 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()) {
|
||||||
Box(
|
Box(
|
||||||
@@ -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(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
"Найдено: ${devices.size}",
|
"Найдено: ${devices.size}",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user