diff --git a/android-application/build.gradle.kts b/android-application/build.gradle.kts index 55f22a4..3fec3d4 100644 --- a/android-application/build.gradle.kts +++ b/android-application/build.gradle.kts @@ -15,11 +15,11 @@ if (useGoogleServices) { } android { - compileSdk = 34 + compileSdk = 35 namespace = "com.inkapplications.ack.android" defaultConfig { minSdk = 21 - targetSdk = 34 + targetSdk = 35 multiDexEnabled = true buildConfigField("boolean", "USE_GOOGLE_SERVICES", useGoogleServices.toString()) buildConfigField("String", "COMMIT", optionalStringProperty("commit").buildQuote()) diff --git a/android-application/src/main/AndroidManifest.xml b/android-application/src/main/AndroidManifest.xml index 12fdefb..5717fed 100644 --- a/android-application/src/main/AndroidManifest.xml +++ b/android-application/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + @@ -33,7 +36,8 @@ - + + = Build.VERSION_CODES.Q) { + val hasBackgroundLocation = checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED + + if (!hasBackgroundLocation) { + AlertDialog.Builder(this) + .setTitle("Background Location Permission Required") + .setMessage("To capture data in the background, this app needs access to your location even when the app is closed.\n\nPlease grant \"Allow all the time\" location permission in the application settings.") + .setPositiveButton("Open Settings") { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", packageName, null) + } + startActivity(intent) + } + .setNegativeButton("Cancel", null) + .show() + return + } + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { permissionGate.withPermissions(*captureEvents.getDriverConnectPermissions().toTypedArray()) { lifecycleScope.launch { - if (captureEvents.driverSelection.first() == DriverSelection.Tnc) { - startConnectTncActivity(backgroundCaptureServiceIntent) - } else { - startService(backgroundCaptureServiceIntent) + when (captureEvents.driverSelection.first()) { + DriverSelection.Tnc -> { + startConnectTncActivity(backgroundCaptureServiceIntent) + } + DriverSelection.Audio -> { + startForegroundService(backgroundCaptureAudioServiceIntent) + } + else -> { + startForegroundService(backgroundCaptureServiceIntent) + } } } } diff --git a/android-application/src/main/java/com/inkapplications/ack/android/capture/CaptureScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/capture/CaptureScreen.kt index 4a3939c..5962bd3 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/capture/CaptureScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/capture/CaptureScreen.kt @@ -62,6 +62,7 @@ fun CaptureScreen( ) { Column { Scaffold( + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), bottomBar = { CaptureBottomBar(navController) }, floatingActionButton = { CaptureSettingsFab(settingsSheetState) }, isFloatingActionButtonDocked = true, @@ -197,7 +198,10 @@ private fun CaptureSettingsSheet( controlPanelState: ControlPanelState, captureController: CaptureNavController, settingsSheetState: BottomSheetScaffoldState, -) = Column(horizontalAlignment = Alignment.CenterHorizontally) { +) = Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars), +) { val scope = rememberCoroutineScope() BackHandler(enabled = settingsSheetState.bottomSheetState.isExpanded) { scope.launch { settingsSheetState.bottomSheetState.collapse() } diff --git a/android-application/src/main/java/com/inkapplications/ack/android/capture/service/BackgroundCaptureService.kt b/android-application/src/main/java/com/inkapplications/ack/android/capture/service/BackgroundCaptureService.kt index 3b91346..fe04f56 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/capture/service/BackgroundCaptureService.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/capture/service/BackgroundCaptureService.kt @@ -14,7 +14,7 @@ import javax.inject.Inject * Service that runs in the background to collect and transmit packets. */ @AndroidEntryPoint -class BackgroundCaptureService: Service() { +open class BackgroundCaptureService: Service() { private lateinit var runScope: CoroutineScope @Inject @@ -51,3 +51,5 @@ class BackgroundCaptureService: Service() { super.onDestroy() } } + +class BackgroundCaptureServiceAudio: BackgroundCaptureService() diff --git a/android-application/src/main/java/com/inkapplications/ack/android/log/details/LogDetailsScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/log/details/LogDetailsScreen.kt index e73af9a..3bcf330 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/log/details/LogDetailsScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/log/details/LogDetailsScreen.kt @@ -55,13 +55,21 @@ private fun Details( interactive = false, modifier = Modifier.aspectRatio(16f / 9f), ) - IconButton( - onClick = controller::onBackPressed + Box( + modifier = Modifier + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) ) { - Icon(Icons.Default.ArrowBack, stringResource(R.string.navigate_up)) + IconButton( + onClick = controller::onBackPressed + ) { + Icon(Icons.Default.ArrowBack, stringResource(R.string.navigate_up)) + } } } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = AckTheme.spacing.gutter)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = AckTheme.spacing.gutter) + ) { Text( viewState.name, style = AckTheme.typography.h1, @@ -78,11 +86,15 @@ private fun Details( } } else { Box( - Modifier.padding( - start = AckTheme.spacing.gutter, - top = AckTheme.spacing.gutter, - end = AckTheme.spacing.gutter, - ) + Modifier + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + ) + .padding( + start = AckTheme.spacing.gutter, + top = AckTheme.spacing.gutter, + end = AckTheme.spacing.gutter, + ) ) { NavigationRow( title = { @@ -104,12 +116,16 @@ private fun Details( } } Column( - modifier = Modifier.padding( - top = AckTheme.spacing.content, - start = AckTheme.spacing.gutter, - end = AckTheme.spacing.gutter, - bottom = AckTheme.spacing.gutter, - ), + modifier = Modifier + .padding( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + .padding( + top = AckTheme.spacing.content, + start = AckTheme.spacing.gutter, + end = AckTheme.spacing.gutter, + bottom = AckTheme.spacing.gutter, + ), ) { IconRow( icon = viewState.receiveIcon, diff --git a/android-application/src/main/java/com/inkapplications/ack/android/map/MapScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/map/MapScreen.kt index e0ca480..edde0b9 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/map/MapScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/map/MapScreen.kt @@ -1,9 +1,6 @@ package com.inkapplications.ack.android.map -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.material.FloatingActionButton import androidx.compose.material.Icon import androidx.compose.material.contentColorFor @@ -40,7 +37,11 @@ fun MapScreen( ) val logState = state.selectedItem if (state.selectedItemVisible && logState != null) { - Row (modifier = Modifier.padding(top = AckTheme.spacing.gutter)) { + Row ( + modifier = Modifier + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + .padding(top = AckTheme.spacing.gutter) + ) { AprsLogItem( log = logState, onClick = onLogItemClick, diff --git a/android-application/src/main/java/com/inkapplications/ack/android/onboard/UsageAgreementPrompt.kt b/android-application/src/main/java/com/inkapplications/ack/android/onboard/UsageAgreementPrompt.kt index 0d631e5..dccef75 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/onboard/UsageAgreementPrompt.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/onboard/UsageAgreementPrompt.kt @@ -18,7 +18,12 @@ fun UsageAgreementPrompt( controller: UserAgreementController, ) { Column( - modifier = Modifier.padding(AckTheme.spacing.gutter) + modifier = Modifier + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + .padding(AckTheme.spacing.gutter) .fillMaxHeight() .verticalScroll(rememberScrollState()), ) { diff --git a/android-application/src/main/java/com/inkapplications/ack/android/settings/SettingsScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/settings/SettingsScreen.kt index 8539dbf..4e3bcf7 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/settings/SettingsScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/settings/SettingsScreen.kt @@ -27,7 +27,13 @@ fun SettingsScreen( controller: SettingsController, ) = AckScreen { Column( - modifier = Modifier.verticalScroll(rememberScrollState()), + modifier = Modifier + .fillMaxSize() + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + .verticalScroll(rememberScrollState()), ) { NavigationRow( title = stringResource(R.string.settings_title), diff --git a/android-application/src/main/java/com/inkapplications/ack/android/settings/license/LicensePrompt.kt b/android-application/src/main/java/com/inkapplications/ack/android/settings/license/LicensePrompt.kt index 130f342..cb1c3b8 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/settings/license/LicensePrompt.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/settings/license/LicensePrompt.kt @@ -20,7 +20,12 @@ fun LicensePromptScreen( onContinue: (LicensePromptFieldValues) -> Unit, ) = AckScreen { Column ( - modifier = Modifier.padding(AckTheme.spacing.gutter), + modifier = Modifier + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + .padding(AckTheme.spacing.gutter), ) { Text( "License Info", diff --git a/android-application/src/main/java/com/inkapplications/ack/android/station/StationScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/station/StationScreen.kt index 72c37b1..69fac28 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/station/StationScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/station/StationScreen.kt @@ -50,13 +50,21 @@ private fun StationDetails( interactive = false, modifier = Modifier.aspectRatio(16f / 9f), ) - IconButton( - onClick = controller::onBackPressed + Box( + modifier = Modifier + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) ) { - Icon(Icons.Default.ArrowBack, stringResource(R.string.navigate_up)) + IconButton( + onClick = controller::onBackPressed + ) { + Icon(Icons.Default.ArrowBack, stringResource(R.string.navigate_up)) + } } } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = AckTheme.spacing.gutter)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = AckTheme.spacing.gutter) + ) { Text( viewState.insight.name, style = AckTheme.typography.h1, @@ -69,11 +77,15 @@ private fun StationDetails( } } else { Box( - Modifier.padding( - start = AckTheme.spacing.gutter, - top = AckTheme.spacing.gutter, - end = AckTheme.spacing.gutter, - ) + Modifier + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + ) + .padding( + start = AckTheme.spacing.gutter, + top = AckTheme.spacing.gutter, + end = AckTheme.spacing.gutter, + ) ) { NavigationRow( title = { @@ -91,12 +103,16 @@ private fun StationDetails( } } Column( - modifier = Modifier.padding( - top = AckTheme.spacing.content, - start = AckTheme.spacing.gutter, - end = AckTheme.spacing.gutter, - bottom = AckTheme.spacing.gutter, - ), + modifier = Modifier + .padding( + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ) + .padding( + top = AckTheme.spacing.content, + start = AckTheme.spacing.gutter, + end = AckTheme.spacing.gutter, + bottom = AckTheme.spacing.gutter, + ), ) { if (viewState.insight.temperature != null) { IconRow(Icons.Default.WbSunny, viewState.insight.temperature) diff --git a/android-application/src/main/java/com/inkapplications/ack/android/symbol/AndroidSymbolFactory.kt b/android-application/src/main/java/com/inkapplications/ack/android/symbol/AndroidSymbolFactory.kt index 60de487..76a5a0e 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/symbol/AndroidSymbolFactory.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/symbol/AndroidSymbolFactory.kt @@ -37,7 +37,7 @@ class AndroidSymbolFactory @Inject constructor( } private fun Bitmap.withOverlay(other: Bitmap): Bitmap { - val bmOverlay = Bitmap.createBitmap(width, height, config) + val bmOverlay = Bitmap.createBitmap(width, height, config ?: Bitmap.Config.ARGB_8888) val canvas = Canvas(bmOverlay) canvas.drawBitmap(this, Matrix(), null) canvas.drawBitmap(other, 0f, 0f, null) diff --git a/android-application/src/main/java/com/inkapplications/ack/android/tnc/ConnectTncScreen.kt b/android-application/src/main/java/com/inkapplications/ack/android/tnc/ConnectTncScreen.kt index e774c7b..cbd5acc 100644 --- a/android-application/src/main/java/com/inkapplications/ack/android/tnc/ConnectTncScreen.kt +++ b/android-application/src/main/java/com/inkapplications/ack/android/tnc/ConnectTncScreen.kt @@ -33,6 +33,10 @@ fun DeviceList( is ConnectTncState.Discovering.Empty -> { Box( modifier = Modifier.fillMaxSize() + .padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + ), ) { DeviceHeader(controller::onCloseClick) Column( diff --git a/aprs-android/build.gradle.kts b/aprs-android/build.gradle.kts index 59e952e..f20697d 100644 --- a/aprs-android/build.gradle.kts +++ b/aprs-android/build.gradle.kts @@ -15,7 +15,7 @@ sqldelight { android { namespace = "com.inkapplications.ack.data" - compileSdk = 33 + compileSdk = 34 ndkVersion = "21.3.6528147" defaultConfig { diff --git a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/AfskDriver.kt b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/AfskDriver.kt index 3780b28..cba9778 100644 --- a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/AfskDriver.kt +++ b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/AfskDriver.kt @@ -35,12 +35,24 @@ class AfskDriver internal constructor( } override val incoming = MutableSharedFlow() override val receivePermissions: Set = when { -// Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> setOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) + Build.VERSION.SDK_INT >= 34 -> setOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.FOREGROUND_SERVICE_MICROPHONE, + // TODO: This could be simplified/removed if we create separate services for transmit and receive. + // Leaving for now, since all the other drivers require it anyway. + Manifest.permission.FOREGROUND_SERVICE_LOCATION, + Manifest.permission.POST_NOTIFICATIONS, + ) + Build.VERSION.SDK_INT >= 33 -> setOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.POST_NOTIFICATIONS, + ) else -> setOf(Manifest.permission.RECORD_AUDIO) } override val transmitPermissions: Set = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> setOf(Manifest.permission.POST_NOTIFICATIONS) + Build.VERSION.SDK_INT >= 33 -> setOf( + Manifest.permission.POST_NOTIFICATIONS + ) else -> emptySet() } override val volume = audioProcessor.volume diff --git a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/InternetDriver.kt b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/InternetDriver.kt index 4afb0b3..579a685 100644 --- a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/InternetDriver.kt +++ b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/InternetDriver.kt @@ -32,11 +32,21 @@ class InternetDriver internal constructor( override val connectionState: Flow = clientConnectionState override val incoming = MutableSharedFlow() override val receivePermissions: Set = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> setOf(Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.ACCESS_FINE_LOCATION) + Build.VERSION.SDK_INT >= 34 -> setOf( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_LOCATION, + ) + Build.VERSION.SDK_INT >= 33 -> setOf( + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.ACCESS_FINE_LOCATION, + ) else -> setOf(Manifest.permission.ACCESS_FINE_LOCATION) } override val transmitPermissions: Set = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> setOf(Manifest.permission.POST_NOTIFICATIONS) + Build.VERSION.SDK_INT >= 33 -> setOf( + Manifest.permission.POST_NOTIFICATIONS, + ) else -> emptySet() } private val transmitQueue = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) diff --git a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/TncDriver.kt b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/TncDriver.kt index 585296c..83836ca 100644 --- a/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/TncDriver.kt +++ b/aprs-android/src/main/java/com/inkapplications/ack/data/drivers/TncDriver.kt @@ -49,11 +49,31 @@ class TncDriver internal constructor( } } override val receivePermissions: Set = when { - Build.VERSION.SDK_INT > Build.VERSION_CODES.S -> setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + Build.VERSION.SDK_INT >= 34 -> setOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_LOCATION, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + ) + Build.VERSION.SDK_INT > 31 -> setOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + ) else -> setOf(Manifest.permission.ACCESS_FINE_LOCATION) } override val transmitPermissions: Set = when { - Build.VERSION.SDK_INT > Build.VERSION_CODES.S -> setOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + Build.VERSION.SDK_INT >= 34 -> setOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.FOREGROUND_SERVICE_LOCATION, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN + ) + Build.VERSION.SDK_INT > 31 -> setOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN + ) else -> setOf(Manifest.permission.ACCESS_FINE_LOCATION) } private val outputStream = MutableStateFlow(null)