diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml index 3ceae8d..c31a86b 100644 --- a/app/src/androidMain/AndroidManifest.xml +++ b/app/src/androidMain/AndroidManifest.xml @@ -10,6 +10,10 @@ + + + + + diff --git a/app/src/androidMain/kotlin/com/aerotoss/AeroTossService.kt b/app/src/androidMain/kotlin/com/aerotoss/AeroTossService.kt new file mode 100644 index 0000000..2833858 --- /dev/null +++ b/app/src/androidMain/kotlin/com/aerotoss/AeroTossService.kt @@ -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 + } +} diff --git a/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt index 6b98387..f0d6790 100644 --- a/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt +++ b/app/src/androidMain/kotlin/com/aerotoss/MainActivity.kt @@ -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)) } } diff --git a/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt index 23111ad..75487e6 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/core/AeroTossManager.kt @@ -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 { @@ -38,10 +59,13 @@ class AeroTossManager( } fun stop() { + autoScanJob?.cancel() discovery.stopDiscovery() } fun dispose() { + autoScanJob?.cancel() + scope.cancel() discovery.dispose() transfer.dispose() } diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt index f87f534..f599cf3 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/HomeScreen.kt @@ -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) }) } } } diff --git a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt index 1ffc119..8fc10db 100644 --- a/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt +++ b/app/src/commonMain/kotlin/com/aerotoss/ui/screens/SendScreen.kt @@ -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)