From caf9cc8a817bd23a418af7b4f16d5ecd001e3db1 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 27 May 2026 00:53:10 +0530 Subject: [PATCH 1/6] refactor: code optimization --- app/build.gradle.kts | 12 +- .../airsync/ExampleInstrumentedTest.kt | 6 +- app/src/main/AndroidManifest.xml | 44 +- .../java/com/sameerasw/airsync/AirSyncApp.kt | 10 +- .../com/sameerasw/airsync/MainActivity.kt | 68 +- .../airsync/data/ble/BleChunkUtil.kt | 24 +- .../airsync/data/ble/BleConnectionManager.kt | 16 +- .../airsync/data/ble/BleConstants.kt | 2 +- .../airsync/data/ble/BleGattServer.kt | 262 ++++- .../airsync/data/ble/BleTransportBridge.kt | 76 +- .../airsync/data/local/DataStoreManager.kt | 9 +- .../ui/activities/ClipboardActionActivity.kt | 15 +- .../ui/activities/PermissionsActivity.kt | 3 +- .../ui/activities/QRScannerActivity.kt | 1 - .../ui/components/AboutSection.kt | 11 +- .../ui/components/AirSyncFloatingToolbar.kt | 10 +- .../ui/components/FloatingMediaPlayer.kt | 100 +- .../ui/components/HelpAndGuides.kt | 26 +- .../ui/components/RotatingAppIcon.kt | 32 +- .../ui/components/RoundedCardContainer.kt | 1 - .../ui/components/SettingsView.kt | 15 +- .../ui/components/cards/BleSyncCard.kt | 65 +- .../components/cards/ConnectionStatusCard.kt | 221 ++-- .../ui/components/cards/DeveloperModeCard.kt | 272 ++--- .../ui/components/cards/DeviceInfoCard.kt | 24 +- .../ui/components/cards/IconToggleItem.kt | 20 +- .../cards/LastConnectedDeviceCard.kt | 154 +-- .../components/cards/ManualConnectionCard.kt | 158 +-- .../cards/QuickSettingsTilesCard.kt | 5 +- .../components/cards/RemoteFunctionsCard.kt | 4 - .../ui/components/dialogs/PermissionDialog.kt | 9 +- .../pickers/CrashReportingPicker.kt | 16 +- .../components/sheets/AppSelectionSheets.kt | 39 +- .../ui/components/sliders/ConfigSliderItem.kt | 23 +- .../ui/composables/WelcomeScreen.kt | 74 +- .../ui/modifiers/ProgressiveBlurModifier.kt | 32 +- .../ui/screens/AirSyncMainScreen.kt | 1032 +++++++++-------- .../ui/screens/PermissionsScreen.kt | 12 +- .../ui/screens/RemoteControlScreen.kt | 26 +- .../viewmodel/AirSyncViewModel.kt | 31 +- .../quickshare/InboundQuickShareConnection.kt | 78 +- .../quickshare/QuickShareAdvertiser.kt | 27 +- .../quickshare/QuickShareConnection.kt | 16 +- .../airsync/quickshare/QuickShareServer.kt | 7 +- .../airsync/quickshare/QuickShareService.kt | 143 ++- .../airsync/quickshare/Ukey2Context.kt | 69 +- .../airsync/service/AirSyncService.kt | 5 +- .../airsync/service/MacMediaPlayerService.kt | 30 +- .../service/MediaNotificationListener.kt | 8 +- .../service/NotificationActionReceiver.kt | 13 +- .../airsync/utils/AdbMdnsDiscovery.kt | 7 +- .../airsync/utils/CallControlUtil.kt | 18 +- .../sameerasw/airsync/utils/DeviceInfoUtil.kt | 6 +- .../airsync/utils/DevicePreviewResolver.kt | 1 - .../com/sameerasw/airsync/utils/JsonUtil.kt | 3 +- .../airsync/utils/MacDeviceStatusManager.kt | 5 +- .../sameerasw/airsync/utils/MacModelMapper.kt | 78 +- .../sameerasw/airsync/utils/PermissionUtil.kt | 22 +- .../sameerasw/airsync/utils/ServiceManager.kt | 4 +- .../sameerasw/airsync/utils/ShortcutUtil.kt | 43 +- .../sameerasw/airsync/utils/SyncManager.kt | 10 +- .../airsync/utils/UDPDiscoveryManager.kt | 13 +- .../sameerasw/airsync/utils/WebDavServer.kt | 52 +- .../airsync/utils/WebSocketMessageHandler.kt | 33 +- .../sameerasw/airsync/utils/WebSocketUtil.kt | 111 +- .../airsync/widget/AirSyncWidgetProvider.kt | 13 +- app/src/main/res/drawable/brand_github.xml | 6 +- app/src/main/res/drawable/brand_telegram.xml | 6 +- app/src/main/res/drawable/essentials_icon.xml | 77 +- app/src/main/res/drawable/ic_clipboard_24.xml | 20 +- app/src/main/res/drawable/ic_desktop_24.xml | 15 +- app/src/main/res/drawable/ic_laptop_24.xml | 10 +- .../res/drawable/ic_launcher_background.xml | 2 +- .../res/drawable/ic_launcher_monochrome.xml | 2 +- app/src/main/res/drawable/ic_mac_mini_24.xml | 15 +- app/src/main/res/drawable/ic_mac_pro_24.xml | 15 +- .../main/res/drawable/ic_mac_studio_24.xml | 15 +- app/src/main/res/drawable/ic_stat_name.xml | 10 +- app/src/main/res/drawable/imac_gen2.xml | 35 +- app/src/main/res/drawable/imac_gen3.xml | 41 +- app/src/main/res/drawable/key_command.xml | 14 +- app/src/main/res/drawable/key_control.xml | 6 +- app/src/main/res/drawable/key_option.xml | 14 +- app/src/main/res/drawable/key_shift.xml | 6 +- .../main/res/drawable/macbook_air_gen2.xml | 53 +- .../main/res/drawable/macbook_air_gen3.xml | 59 +- app/src/main/res/drawable/macbook_neo.xml | 53 +- .../main/res/drawable/macbook_pro_gen2.xml | 48 +- .../main/res/drawable/macbook_pro_gen3.xml | 54 +- app/src/main/res/drawable/macmini_gen1.xml | 30 +- app/src/main/res/drawable/macmini_gen3.xml | 24 +- app/src/main/res/drawable/macpro_gen3.xml | 61 +- app/src/main/res/drawable/macstudio_gen1.xml | 18 +- .../res/drawable/outline_battery_full_24.xml | 15 +- .../res/drawable/outline_downloading_24.xml | 15 +- .../outline_expand_circle_down_24.xml | 15 +- .../drawable/outline_expand_circle_up_24.xml | 15 +- .../main/res/drawable/outline_feedback_24.xml | 15 +- app/src/main/res/drawable/outline_info_24.xml | 15 +- .../drawable/outline_open_in_browser_24.xml | 15 +- .../main/res/drawable/outline_pause_24.xml | 6 +- .../res/drawable/outline_play_arrow_24.xml | 6 +- .../res/drawable/outline_skip_next_24.xml | 6 +- .../res/drawable/outline_skip_previous_24.xml | 6 +- app/src/main/res/drawable/quick_share.xml | 12 +- app/src/main/res/drawable/rounded_add_24.xml | 15 +- .../main/res/drawable/rounded_android_24.xml | 15 +- .../rounded_android_wifi_3_bar_24.xml | 15 +- .../res/drawable/rounded_arrow_back_24.xml | 16 +- .../res/drawable/rounded_arrow_forward_24.xml | 16 +- .../main/res/drawable/rounded_asterisk_24.xml | 15 +- .../res/drawable/rounded_bluetooth_24.xml | 15 +- .../rounded_bluetooth_searching_24.xml | 15 +- .../main/res/drawable/rounded_blur_on_24.xml | 15 +- .../res/drawable/rounded_brightness_5_24.xml | 15 +- .../res/drawable/rounded_brightness_7_24.xml | 15 +- .../res/drawable/rounded_bug_report_24.xml | 15 +- .../main/res/drawable/rounded_call_log_24.xml | 15 +- .../main/res/drawable/rounded_check_24.xml | 15 +- .../drawable/rounded_compare_arrows_24.xml | 16 +- .../main/res/drawable/rounded_contacts_24.xml | 15 +- .../res/drawable/rounded_dark_mode_24.xml | 15 +- .../main/res/drawable/rounded_devices_24.xml | 15 +- .../res/drawable/rounded_devices_off_24.xml | 15 +- .../res/drawable/rounded_drag_click_24.xml | 15 +- app/src/main/res/drawable/rounded_draw_24.xml | 15 +- .../res/drawable/rounded_extension_24.xml | 15 +- .../drawable/rounded_folder_managed_24.xml | 15 +- .../drawable/rounded_gamepad_circle_up_24.xml | 15 +- .../res/drawable/rounded_heart_smile_24.xml | 15 +- .../main/res/drawable/rounded_history_24.xml | 15 +- app/src/main/res/drawable/rounded_info_24.xml | 15 +- .../res/drawable/rounded_invert_colors_24.xml | 15 +- .../rounded_keyboard_arrow_right_24.xml | 16 +- .../res/drawable/rounded_laptop_mac_24.xml | 10 +- .../main/res/drawable/rounded_link_off_24.xml | 15 +- .../rounded_local_laundry_service_24.xml | 15 +- app/src/main/res/drawable/rounded_lock_24.xml | 15 +- .../drawable/rounded_mimo_disconnect_24.xml | 15 +- .../res/drawable/rounded_mobile_check_24.xml | 15 +- .../drawable/rounded_mobile_vibrate_24.xml | 15 +- .../drawable/rounded_mobiledata_arrows_24.xml | 15 +- .../res/drawable/rounded_music_cast_24.xml | 15 +- .../res/drawable/rounded_network_node_24.xml | 15 +- .../rounded_notification_settings_24.xml | 15 +- .../rounded_notifications_active_24.xml | 15 +- .../drawable/rounded_qr_code_scanner_24.xml | 15 +- .../main/res/drawable/rounded_remove_24.xml | 15 +- .../rounded_screenshot_monitor_24.xml | 15 +- .../main/res/drawable/rounded_security_24.xml | 15 +- .../drawable/rounded_settings_phone_24.xml | 15 +- .../res/drawable/rounded_shield_toggle_24.xml | 15 +- .../res/drawable/rounded_smart_display_24.xml | 15 +- .../res/drawable/rounded_star_shine_24.xml | 15 +- .../res/drawable/rounded_sync_desktop_24.xml | 15 +- .../main/res/drawable/rounded_task_alt_24.xml | 15 +- .../res/drawable/rounded_troubleshoot_24.xml | 15 +- app/src/main/res/drawable/rounded_web_24.xml | 15 +- .../res/drawable/rounded_web_traffic_24.xml | 15 +- app/src/main/res/drawable/widget_preview.xml | 10 +- .../main/res/drawable/widget_tap_hint_bg.xml | 4 +- app/src/main/res/layout/widget_airsync.xml | 46 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 +- app/src/main/res/values-night/splash.xml | 2 +- app/src/main/res/values-night/themes.xml | 3 +- app/src/main/res/values/splash.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- app/src/main/res/values/themes.xml | 26 +- .../main/res/xml/network_security_config.xml | 2 +- app/src/main/res/xml/widget_airsync_info.xml | 2 +- .../com/sameerasw/airsync/ExampleUnitTest.kt | 3 +- 172 files changed, 3298 insertions(+), 2250 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23d0390c..115e6d42 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import org.gradle.api.JavaVersion.VERSION_11 -import org.gradle.api.JavaVersion.VERSION_17 import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -49,11 +47,11 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } } -} buildFeatures { compose = true buildConfig = true @@ -86,7 +84,7 @@ dependencies { // Android 12+ SplashScreen API with backward compatibility attributes implementation("androidx.core:core-splashscreen:1.0.1") - implementation ("androidx.compose.material3:material3:1.5.0-alpha10") + implementation("androidx.compose.material3:material3:1.5.0-alpha10") implementation("androidx.compose.material:material-icons-core:1.7.8") implementation("androidx.compose.material:material-icons-extended:1.7.8") diff --git a/app/src/androidTest/java/com/sameerasw/airsync/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/sameerasw/airsync/ExampleInstrumentedTest.kt index 659a5c25..4557bbf5 100644 --- a/app/src/androidTest/java/com/sameerasw/airsync/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/sameerasw/airsync/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.sameerasw.airsync -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a9f19d1a..5cd44fcb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,14 +19,16 @@ - + + - @@ -57,21 +59,23 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AirSync" - android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="36"> - + + android:theme="@style/Theme.AirSync.Splash"> @@ -87,29 +91,30 @@ + + - + - + android:taskAffinity="" + android:theme="@style/Theme.AirSync.Transparent"> @@ -131,8 +136,8 @@ android:name=".presentation.ui.activities.QRScannerActivity" android:exported="true" android:label="@string/app_name" - android:theme="@style/Theme.AirSync" - android:screenOrientation="portrait"> + android:screenOrientation="portrait" + android:theme="@style/Theme.AirSync"> @@ -151,8 +156,7 @@ - + android:foregroundServiceType="connectedDevice"> - + android:exported="true" + android:permission="com.kieronquinn.app.smartspacer.permission.ACCESS_SMARTSPACER_TARGETS"> @@ -270,8 +273,9 @@ - - + + + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index a01c427b..45f8a4fe 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -15,14 +15,15 @@ class AirSyncApp : Application() { companion object { private var instance: AirSyncApp? = null fun isAppForeground(): Boolean = instance?.isForeground() ?: false - fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager + fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = + instance?.bleConnectionManager } override fun onCreate() { super.onCreate() instance = this initSentry() - + bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) bleConnectionManager.start() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { @@ -30,11 +31,13 @@ class AirSyncApp : Application() { override fun onActivityStarted(activity: Activity) { activityCount++ } + override fun onActivityResumed(activity: Activity) {} override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) { activityCount-- } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) @@ -49,7 +52,8 @@ class AirSyncApp : Application() { if (!isEnabled) return SentryAndroid.init(this) { options -> - options.dsn = "https://cb9b0ead9e88e0818269e773cb662141@o4510996760887296.ingest.de.sentry.io/4511002261389392" + options.dsn = + "https://cb9b0ead9e88e0818269e773cb662141@o4510996760887296.ingest.de.sentry.io/4511002261389392" options.isEnabled = true } } diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index c9848bc0..b71c49da 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -12,37 +12,15 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material.icons.rounded.HelpOutline import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.NavHost @@ -52,7 +30,6 @@ import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity import com.sameerasw.airsync.presentation.ui.screens.AirSyncMainScreen import com.sameerasw.airsync.ui.theme.AirSyncTheme -import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.AdbMdnsDiscovery import com.sameerasw.airsync.utils.ContentCaptureManager import com.sameerasw.airsync.utils.DevicePreviewResolver @@ -246,7 +223,8 @@ class MainActivity : ComponentActivity() { this@MainActivity, R.color.material_primary ) - splashIcon.imageTintList = android.content.res.ColorStateList.valueOf(colorPrimary) + splashIcon.imageTintList = + android.content.res.ColorStateList.valueOf(colorPrimary) Log.d("MainActivity", "Switched to device icon with primary tint") // Fade in the new device icon @@ -299,28 +277,28 @@ class MainActivity : ComponentActivity() { fadeOutIcon.start() } else { // No device icon found, or splashIcon is null/not ImageView (OEM device compatibility) - // Proceed directly to outro after a brief hold - try { - splashScreenView.postDelayed({ - startOutroAnimation( - splashScreenView, - splashIcon, - splashScreenViewProvider - ) - }, 500) - } catch (e: Exception) { - Log.e( - "MainActivity", - "Error scheduling outro with no icon: ${e.message}", - e - ) - // Fallback: start outro immediately + // Proceed directly to outro after a brief hold + try { + splashScreenView.postDelayed({ startOutroAnimation( splashScreenView, splashIcon, splashScreenViewProvider ) - } + }, 500) + } catch (e: Exception) { + Log.e( + "MainActivity", + "Error scheduling outro with no icon: ${e.message}", + e + ) + // Fallback: start outro immediately + startOutroAnimation( + splashScreenView, + splashIcon, + splashScreenViewProvider + ) + } } } catch (e: Exception) { // Fallback for any unexpected exceptions during animation @@ -344,7 +322,10 @@ class MainActivity : ComponentActivity() { AdbDiscoveryHolder.initialize(this) Log.d("MainActivity", "Started persistent ADB discovery at app startup") } else { - Log.d("MainActivity", "Skipping persistent ADB discovery at startup: ACCESS_LOCAL_NETWORK permission not granted") + Log.d( + "MainActivity", + "Skipping persistent ADB discovery at startup: ACCESS_LOCAL_NETWORK permission not granted" + ) } // Check if this is a QS tile long-press intent and device is not connected @@ -412,7 +393,8 @@ class MainActivity : ComponentActivity() { modifier = Modifier.padding(innerPadding) ) { composable("main") { - val initialPage = if (intent?.action == ShortcutUtil.DASH_ACTION_REMOTE) 1 else 0 + val initialPage = + if (intent?.action == ShortcutUtil.DASH_ACTION_REMOTE) 1 else 0 AirSyncMainScreen( initialIp = ip, initialPort = port, diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt index 7504492c..88f781ed 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt @@ -12,7 +12,7 @@ object BleChunkUtil { fun splitIntoChunks(payload: String, mtu: Int): List { val data = payload.toByteArray(Charsets.UTF_8) val maxPayloadSize = mtu - BleConstants.CHUNK_HEADER_SIZE - + if (maxPayloadSize <= 0) { Log.e(TAG, "MTU too small: $mtu") return emptyList() @@ -20,21 +20,21 @@ object BleChunkUtil { val totalChunks = (data.size + maxPayloadSize - 1) / maxPayloadSize val chunks = mutableListOf() - + for (i in 0 until totalChunks) { val start = i * maxPayloadSize val end = minOf(start + maxPayloadSize, data.size) val chunkData = data.sliceArray(start until end) - + val chunk = ByteArray(BleConstants.CHUNK_HEADER_SIZE + chunkData.size) val buffer = java.nio.ByteBuffer.wrap(chunk) buffer.putShort(i.toShort()) buffer.putShort(totalChunks.toShort()) - + chunkData.copyInto(chunk, BleConstants.CHUNK_HEADER_SIZE) chunks.add(chunk) } - + return chunks } @@ -45,20 +45,20 @@ object BleChunkUtil { fun reassemble(chunks: Map): String { val sortedIndices = chunks.keys.sorted() if (sortedIndices.isEmpty()) return "" - + val totalSize = chunks.values.sumOf { it.size } val result = ByteArray(totalSize) - + var offset = 0 for (index in sortedIndices) { val chunk = chunks[index] ?: continue chunk.copyInto(result, offset) offset += chunk.size } - + return String(result, Charsets.UTF_8) } - + /** * Extracts header information from a raw BLE packet. */ @@ -89,15 +89,15 @@ object BleChunkUtil { val header = parseHeader(packet) ?: return null val current = header.first val total = header.second - + if (totalChunks != -1 && totalChunks != total) { // New transmission started or mismatch, reset chunks.clear() } totalChunks = total - + chunks[current] = getPayload(packet) - + if (chunks.size == totalChunks) { val result = reassemble(chunks) chunks.clear() diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt index a74882b0..cc161b3b 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -4,28 +4,34 @@ import android.content.Context import android.util.Log import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.utils.WebSocketUtil -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch class BleConnectionManager(private val context: Context) { companion object { private const val TAG = "BleConnectionManager" } - + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val dataStoreManager = DataStoreManager(context) private var bleServer: BleGattServer? = null - + private var isBleEnabled = false @OptIn(ExperimentalCoroutinesApi::class) private val _serverFlow = kotlinx.coroutines.flow.MutableStateFlow(null) - + @OptIn(ExperimentalCoroutinesApi::class) val connectionState = _serverFlow.flatMapLatest { server -> - server?.connectionState ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED) + server?.connectionState + ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED) } fun start() { diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt index 78d5c7c9..113e5c7d 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt @@ -44,7 +44,7 @@ object BleConstants { // Chunking const val MAX_MTU = 512 const val CHUNK_HEADER_SIZE = 4 // [index: UInt16][total: UInt16] - + // Delimiter for compact strings const val DELIMITER = "\u001F" } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt index 671ec295..5d99ed16 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -1,7 +1,15 @@ package com.sameerasw.airsync.data.ble import android.annotation.SuppressLint -import android.bluetooth.* +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile import android.bluetooth.le.AdvertiseCallback import android.bluetooth.le.AdvertiseData import android.bluetooth.le.AdvertiseSettings @@ -9,14 +17,21 @@ import android.content.Context import android.os.ParcelUuid import android.util.Log import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.ClipboardSyncManager import com.sameerasw.airsync.utils.MacDeviceStatusManager import com.sameerasw.airsync.utils.NotificationDismissalUtil -import com.sameerasw.airsync.utils.ClipboardSyncManager -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import java.util.* +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.UUID import java.util.concurrent.ConcurrentLinkedQueue @SuppressLint("MissingPermission") @@ -31,7 +46,8 @@ class BleGattServer(private val context: Context) { instance = this } - private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val bluetoothManager = + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager private val adapter = bluetoothManager.adapter private var gattServer: BluetoothGattServer? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -91,14 +107,17 @@ class BleGattServer(private val context: Context) { } catch (e: Exception) { "" } - val rawName = if (customName.isNotBlank()) customName else com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName(context) + val rawName = + if (customName.isNotBlank()) customName else com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName( + context + ) val baseName = rawName .replace("AirSync-AirSync-", "") .replace("AirSync-", "") .replace("airsync-", "") .replace("airsync", "") .trim() - + val bleName = "AirSync-$baseName" try { if (adapter.name != bleName) { @@ -130,11 +149,14 @@ class BleGattServer(private val context: Context) { private fun setupGattServer() { gattServer = bluetoothManager.openGattServer(context, gattServerCallback) - + pendingServices.clear() // System Service - val systemService = BluetoothGattService(BleConstants.SERVICE_SYSTEM, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val systemService = BluetoothGattService( + BleConstants.SERVICE_SYSTEM, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) systemService.addCharacteristic(createReadCharacteristic(BleConstants.CHAR_PROTOCOL_VERSION)) systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_AUTH_TOKEN)) systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_AUTH_RESULT)) @@ -146,7 +168,10 @@ class BleGattServer(private val context: Context) { pendingServices.add(systemService) // Notifications Service - val notifService = BluetoothGattService(BleConstants.SERVICE_NOTIFICATIONS, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val notifService = BluetoothGattService( + BleConstants.SERVICE_NOTIFICATIONS, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DATA)) notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_ACTION)) notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS)) @@ -154,14 +179,20 @@ class BleGattServer(private val context: Context) { pendingServices.add(notifService) // Media Service - val mediaService = BluetoothGattService(BleConstants.SERVICE_MEDIA, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val mediaService = BluetoothGattService( + BleConstants.SERVICE_MEDIA, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) mediaService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MEDIA_STATE)) mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MEDIA_CONTROL)) mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_MEDIA_STATE)) pendingServices.add(mediaService) // Clipboard Service - val clipService = BluetoothGattService(BleConstants.SERVICE_CLIPBOARD, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val clipService = BluetoothGattService( + BleConstants.SERVICE_CLIPBOARD, + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) clipService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY)) clipService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_WRITE)) pendingServices.add(clipService) @@ -182,7 +213,7 @@ class BleGattServer(private val context: Context) { if (currentAdvertiseCallback != null) { stopAdvertising() } - + val advertiser = adapter.bluetoothLeAdvertiser ?: return val settings = AdvertiseSettings.Builder() @@ -268,7 +299,10 @@ class BleGattServer(private val context: Context) { } override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { - Log.d(TAG, "onConnectionStateChange: device=${device.address}, status=$status, newState=$newState, bond=${device.bondState}") + Log.d( + TAG, + "onConnectionStateChange: device=${device.address}, status=$status, newState=$newState, bond=${device.bondState}" + ) if (newState == BluetoothProfile.STATE_CONNECTED) { Log.d(TAG, "Device connected: ${device.address}") @@ -279,7 +313,8 @@ class BleGattServer(private val context: Context) { connectedDevices.remove(device) if (connectedDevices.isEmpty()) { stopHeartbeat() - _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED + _connectionState.value = + if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED isAuthenticated = false if (gattServer != null) { val isEnabled = try { @@ -305,48 +340,109 @@ class BleGattServer(private val context: Context) { negotiatedMtu = mtu } - override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic) { + override fun onCharacteristicReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + characteristic: BluetoothGattCharacteristic + ) { if (characteristic.uuid == BleConstants.CHAR_PROTOCOL_VERSION) { - gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, byteArrayOf(BleConstants.PROTOCOL_VERSION.toByte())) + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + byteArrayOf(BleConstants.PROTOCOL_VERSION.toByte()) + ) } else { - gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null) + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_READ_NOT_PERMITTED, + 0, + null + ) } } private val chunkBuffers = mutableMapOf>() - override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") - + if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) { - Log.w(TAG, "Blocked unauthorized write request to ${characteristic.uuid} from ${device.address}") + Log.w( + TAG, + "Blocked unauthorized write request to ${characteristic.uuid} from ${device.address}" + ) if (responseNeeded) { - gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_WRITE_NOT_PERMITTED, offset, null) + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_WRITE_NOT_PERMITTED, + offset, + null + ) } return } - + when (characteristic.uuid) { BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) - BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } - BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(characteristic.uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } - BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(characteristic.uuid, value) { handleMacMediaState(it) } - BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(characteristic.uuid, value) { + BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite( + characteristic.uuid, + value + ) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } + + BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite( + characteristic.uuid, + value + ) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } + + BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite( + characteristic.uuid, + value + ) { handleMacMediaState(it) } + + BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite( + characteristic.uuid, + value + ) { Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}") ClipboardSyncManager.handleClipboardUpdate(context, it) } - BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(characteristic.uuid, value) { + + BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(characteristic.uuid, value) { Log.d(TAG, "Received Mac Device Name: $it") // Update Mac name in status manager MacDeviceStatusManager.updateMacStatus(context, name = it) } - BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationDismiss(it) } + + BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite( + characteristic.uuid, + value + ) { handleNotificationDismiss(it) } + else -> Log.w(TAG, "Unknown characteristic write: ${characteristic.uuid}") } if (responseNeeded) { - gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + value + ) } } @@ -359,10 +455,10 @@ class BleGattServer(private val context: Context) { } val (current, total) = header val payload = BleChunkUtil.getPayload(value) - + val buffer = chunkBuffers.getOrPut(uuid) { mutableMapOf() } buffer[current] = payload - + if (buffer.size == total) { val completePayload = BleChunkUtil.reassemble(buffer) chunkBuffers.remove(uuid) @@ -370,15 +466,37 @@ class BleGattServer(private val context: Context) { } } - override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor) { + override fun onDescriptorReadRequest( + device: BluetoothDevice, + requestId: Int, + offset: Int, + descriptor: BluetoothGattDescriptor + ) { Log.d(TAG, "Descriptor read request: ${descriptor.uuid}") gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) } - override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { - Log.d(TAG, "Descriptor write request: ${descriptor.uuid}, value: ${value.contentToString()}") + override fun onDescriptorWriteRequest( + device: BluetoothDevice, + requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + Log.d( + TAG, + "Descriptor write request: ${descriptor.uuid}, value: ${value.contentToString()}" + ) if (responseNeeded) { - gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + gattServer?.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + offset, + value + ) } } @@ -392,12 +510,15 @@ class BleGattServer(private val context: Context) { scope.launch { val deviceData = dataStoreManager.getLastConnectedDevice().first() val storedKey = deviceData?.symmetricKey - Log.d(TAG, "Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}") - + Log.d( + TAG, + "Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}" + ) + if (storedKey != null) { val expectedToken = BleTransportBridge.deriveAuthToken(storedKey) val receivedTokenStr = String(token, Charsets.UTF_8) - + Log.d(TAG, "Expected token: $expectedToken") Log.d(TAG, "Received token: $receivedTokenStr") @@ -405,16 +526,25 @@ class BleGattServer(private val context: Context) { Log.i(TAG, "BLE Auth Success!") isAuthenticated = true _connectionState.value = BleConnectionState.AUTHENTICATED - sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) + sendNotification( + BleConstants.CHAR_AUTH_RESULT, + byteArrayOf(BleConstants.AUTH_SUCCESS) + ) BleTransportBridge.sendDeviceName() startHeartbeat() } else { Log.w(TAG, "BLE Auth Failed! Token mismatch.") - sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + sendNotification( + BleConstants.CHAR_AUTH_RESULT, + byteArrayOf(BleConstants.AUTH_FAILED) + ) } } else { Log.w(TAG, "BLE Auth Failed! No symmetric key found for last connected device.") - sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + sendNotification( + BleConstants.CHAR_AUTH_RESULT, + byteArrayOf(BleConstants.AUTH_FAILED) + ) } } } @@ -425,7 +555,8 @@ class BleGattServer(private val context: Context) { while (isActive && isAuthenticated) { delay(5000) if (connectedDevices.isNotEmpty()) { - val level = com.sameerasw.airsync.utils.DeviceInfoUtil.getBatteryInfo(context).level + val level = + com.sameerasw.airsync.utils.DeviceInfoUtil.getBatteryInfo(context).level sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) } } @@ -469,7 +600,7 @@ class BleGattServer(private val context: Context) { val isMuted = parts[4] == "1" val likeStatus = parts[5] val albumArt = if (parts.size >= 7) parts[6] else null - + Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") MacDeviceStatusManager.updateMusicStatus( context, isPlaying, title, artist, volume, isMuted, likeStatus, albumArt @@ -494,11 +625,11 @@ class BleGattServer(private val context: Context) { */ fun sendNotification(characteristicUuid: UUID, data: ByteArray) { if (connectedDevices.isEmpty()) return - + // Characteristic level queue to ensure order val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } queue.add(data) - + if (isSending[characteristicUuid] != true) { processNextInQueue(characteristicUuid) } @@ -506,18 +637,18 @@ class BleGattServer(private val context: Context) { fun sendChunkedNotification(characteristicUuid: UUID, payload: String) { if (connectedDevices.isEmpty()) return - + // Truncate notification text to conserve BLE bandwidth val truncatedPayload = if (characteristicUuid == BleConstants.CHAR_NOTIFICATION_DATA) { - payload.take(500) + payload.take(500) } else payload val mtu = negotiatedMtu val chunks = BleChunkUtil.splitIntoChunks(truncatedPayload, mtu) - + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } chunks.forEach { queue.add(it) } - + if (isSending[characteristicUuid] != true) { processNextInQueue(characteristicUuid) } @@ -535,12 +666,12 @@ class BleGattServer(private val context: Context) { private fun processNextInQueue(uuid: UUID) { val queue = characteristicQueues[uuid] ?: return val data = queue.poll() ?: return - + isSending[uuid] = true - + val characteristic = findCharacteristic(uuid) ?: return characteristic.value = data - + connectedDevices.forEach { device -> gattServer?.notifyCharacteristicChanged(device, characteristic, false) } @@ -554,19 +685,32 @@ class BleGattServer(private val context: Context) { } private fun createReadCharacteristic(uuid: UUID): BluetoothGattCharacteristic { - return BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ) + return BluetoothGattCharacteristic( + uuid, + BluetoothGattCharacteristic.PROPERTY_READ, + BluetoothGattCharacteristic.PERMISSION_READ + ) } private fun createWriteCharacteristic(uuid: UUID): BluetoothGattCharacteristic { - return BluetoothGattCharacteristic(uuid, - BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, - BluetoothGattCharacteristic.PERMISSION_WRITE) + return BluetoothGattCharacteristic( + uuid, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE + ) } private fun createNotifyCharacteristic(uuid: UUID): BluetoothGattCharacteristic { - val char = BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ) + val char = BluetoothGattCharacteristic( + uuid, + BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_READ + ) // Add CCCD for notification support - val configDescriptor = BluetoothGattDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE) + val configDescriptor = BluetoothGattDescriptor( + UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), + BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE + ) char.addDescriptor(configDescriptor) return char } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index 07fe68f3..b11a7d9c 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -1,17 +1,17 @@ package com.sameerasw.airsync.data.ble import android.util.Log -import com.sameerasw.airsync.domain.model.BatteryInfo import com.sameerasw.airsync.domain.model.AudioInfo -import java.security.MessageDigest -import java.util.* +import com.sameerasw.airsync.domain.model.BatteryInfo import com.sameerasw.airsync.utils.CallControlUtil import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import java.security.MessageDigest +import java.util.Base64 object BleTransportBridge { private const val TAG = "BleTransportBridge" - + private var gattServer: BleGattServer? = null fun initialize(server: BleGattServer) { @@ -51,7 +51,7 @@ object BleTransportBridge { audio.likeStatus, "" // Avoid sending heavy base64 art over BLE to conserve bandwidth ).joinToString(BleConstants.DELIMITER) - + gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) } @@ -60,7 +60,7 @@ object BleTransportBridge { if (isDnd) "1" else "0", if (isPowerSave) "1" else "0" ).joinToString(BleConstants.DELIMITER) - + gattServer?.sendNotification(BleConstants.CHAR_SYSTEM_STATE, payload.toByteArray()) } @@ -84,14 +84,26 @@ object BleTransportBridge { when { action == "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) action == "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) - action == "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + action == "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious( + context + ) + action == "callAccept" -> CallControlUtil.acceptCall(context) action == "callDecline" || action == "callEnd" -> CallControlUtil.endCall(context) - + // Volume Controls over BLE - action == "volumeUp" -> com.sameerasw.airsync.utils.VolumeControlUtil.increaseVolume(context) - action == "volumeDown" -> com.sameerasw.airsync.utils.VolumeControlUtil.decreaseVolume(context) - action == "muteToggle" -> com.sameerasw.airsync.utils.VolumeControlUtil.toggleMute(context) + action == "volumeUp" -> com.sameerasw.airsync.utils.VolumeControlUtil.increaseVolume( + context + ) + + action == "volumeDown" -> com.sameerasw.airsync.utils.VolumeControlUtil.decreaseVolume( + context + ) + + action == "muteToggle" -> com.sameerasw.airsync.utils.VolumeControlUtil.toggleMute( + context + ) + action.startsWith("setVolume|") -> { val volStr = action.substringAfter("setVolume|") val vol = volStr.toIntOrNull() @@ -99,6 +111,7 @@ object BleTransportBridge { com.sameerasw.airsync.utils.VolumeControlUtil.setVolume(context, vol) } } + action.startsWith("toggleNotif|") -> { val parts = action.split("|") if (parts.size >= 3) { @@ -107,16 +120,27 @@ object BleTransportBridge { Log.d(TAG, "Received toggleAppNotif via BLE: pkg=$pkg, state=$state") kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { try { - val dataStoreManager = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) - val currentApps = dataStoreManager.getNotificationApps().first().toMutableList() + val dataStoreManager = + com.sameerasw.airsync.data.local.DataStoreManager.getInstance( + context + ) + val currentApps = + dataStoreManager.getNotificationApps().first().toMutableList() val idx = currentApps.indexOfFirst { it.packageName == pkg } if (idx != -1) { - currentApps[idx] = currentApps[idx].copy(isEnabled = state, lastUpdated = System.currentTimeMillis()) + currentApps[idx] = currentApps[idx].copy( + isEnabled = state, + lastUpdated = System.currentTimeMillis() + ) dataStoreManager.saveNotificationApps(currentApps) - Log.d(TAG, "Successfully toggled app notification preference via BLE for $pkg to $state") + Log.d( + TAG, + "Successfully toggled app notification preference via BLE for $pkg to $state" + ) } else { val isSystemApp = try { - val applicationInfo = context.packageManager.getApplicationInfo(pkg, 0) + val applicationInfo = + context.packageManager.getApplicationInfo(pkg, 0) (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 } catch (_: Exception) { false @@ -130,11 +154,20 @@ object BleTransportBridge { ) currentApps.add(newApp) dataStoreManager.saveNotificationApps(currentApps) - Log.d(TAG, "Saved new app notification preference via BLE for $pkg to $state") + Log.d( + TAG, + "Saved new app notification preference via BLE for $pkg to $state" + ) } - com.sameerasw.airsync.utils.SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) + com.sameerasw.airsync.utils.SyncManager.checkAndSyncDeviceStatus( + context, + forceSync = true + ) } catch (e: Exception) { - Log.e(TAG, "Error toggling app notification preference via BLE: ${e.message}") + Log.e( + TAG, + "Error toggling app notification preference via BLE: ${e.message}" + ) } } } @@ -148,7 +181,10 @@ object BleTransportBridge { if (parts.size >= 2) { val id = parts[0] val actionName = parts[1] - com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(id, actionName) + com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction( + id, + actionName + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index a7aad644..1ffdc453 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt @@ -95,7 +95,8 @@ class DataStoreManager(private val context: Context) { private val FILE_ACCESS_ENABLED = booleanPreferencesKey("file_access_enabled") // Widget preferences - private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") + private val WIDGET_TRANSPARENCY = + androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") private val REMOTE_FLIPPED = booleanPreferencesKey("remote_flipped") @@ -1031,11 +1032,13 @@ class DataStoreManager(private val context: Context) { context.dataStore.edit { it[BLE_SYNC_ENABLED] = enabled } } - fun getBleSyncEnabled(): Flow = context.dataStore.data.map { it[BLE_SYNC_ENABLED] ?: false } + fun getBleSyncEnabled(): Flow = + context.dataStore.data.map { it[BLE_SYNC_ENABLED] ?: false } suspend fun setBleAutoConnectEnabled(enabled: Boolean) { context.dataStore.edit { it[BLE_AUTO_CONNECT_ENABLED] = enabled } } - fun getBleAutoConnectEnabled(): Flow = context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } + fun getBleAutoConnectEnabled(): Flow = + context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt index ca00177b..b7f2f7c3 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt @@ -13,25 +13,16 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.ContentPaste import androidx.compose.material.icons.rounded.Error -import androidx.compose.material.icons.rounded.ReceiptLong import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator @@ -47,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -62,7 +52,6 @@ import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.ui.theme.AirSyncTheme import com.sameerasw.airsync.utils.ClipboardSyncManager import com.sameerasw.airsync.utils.ClipboardUtil -import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.delay @@ -273,7 +262,7 @@ private fun ClipboardActionScreenContent( ) // Divider or Space if needed, but spacing is enough - + // Status Icon / Loading Indicator AnimatedContent( targetState = uiState, @@ -309,7 +298,7 @@ private fun ClipboardActionScreenContent( modifier = Modifier.size(28.dp) ) } - + else -> { // Default/Idle icon val iconPainter = when (shortcutAction) { diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt index 3e0ad33e..5251e047 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import com.sameerasw.airsync.presentation.ui.screens.PermissionsScreen import com.sameerasw.airsync.ui.theme.AirSyncTheme -import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.PermissionUtil class PermissionsActivity : ComponentActivity() { @@ -177,7 +176,7 @@ class PermissionsActivity : ComponentActivity() { phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) } } - + private fun requestBluetoothPermission() { if (!PermissionUtil.isBluetoothPermissionsGranted(this)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt index 4327ab66..fb2b157a 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt @@ -51,7 +51,6 @@ import androidx.core.content.ContextCompat import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.common.InputImage -import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.ui.theme.AirSyncTheme import com.sameerasw.airsync.utils.HapticUtil import java.util.concurrent.Executors diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt index 47d9385b..1f7d8202 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt @@ -1,6 +1,10 @@ package com.sameerasw.airsync.presentation.ui.components import android.content.Intent +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -15,10 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -38,13 +39,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.foundation.text.ClickableText import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.sameerasw.airsync.R diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt index 5ebc9dc6..5e92435a 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt @@ -8,11 +8,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults @@ -51,11 +49,11 @@ fun AirSyncFloatingToolbar( val configuration = LocalConfiguration.current val fontScale = LocalDensity.current.fontScale val screenWidth = configuration.screenWidthDp - + // Hide label if font scale is large or screen width is too small val isLargeFont = fontScale > 1.25f - val isCompactScreen = screenWidth < 400 - + val isCompactScreen = screenWidth < 400 + val shouldHideLabel = isLargeFont || (isCompactScreen && tabs.size > 3) HorizontalFloatingToolbar( @@ -104,7 +102,7 @@ fun AirSyncFloatingToolbar( ), label = "spacer_width_$index" ) - + // Always render the button, but animate its visibility if (itemWidth > 0.dp || isSelected) { IconButton( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt index 91ccfc2d..ec2e1b95 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt @@ -1,21 +1,24 @@ package com.sameerasw.airsync.presentation.ui.components import android.graphics.Bitmap -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -34,54 +37,37 @@ import androidx.compose.material3.ButtonGroup import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.animation.core.exponentialDecay -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.gestures.animateTo -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import com.sameerasw.airsync.domain.model.MacMusicInfo import com.sameerasw.airsync.utils.HapticUtil import kotlinx.coroutines.launch -import kotlin.math.roundToInt enum class DragValue { Collapsed, Expanded } @@ -120,7 +106,7 @@ fun FloatingMediaPlayer( val collapsedHeight = 72.dp val expandedHeight = 280.dp - + val collapsedPx = with(density) { collapsedHeight.toPx() } val expandedPx = with(density) { expandedHeight.toPx() } @@ -194,7 +180,7 @@ fun FloatingMediaPlayer( ) { // Expand Button IconButton( - onClick = { + onClick = { scope.launch { anchoredDraggableState.animateTo(DragValue.Expanded) } }, modifier = Modifier.size(40.dp) @@ -212,7 +198,8 @@ fun FloatingMediaPlayer( .weight(1f) ) { Text( - text = musicInfo?.title?.takeIf { it.isNotEmpty() } ?: "Nothing Playing", + text = musicInfo?.title?.takeIf { it.isNotEmpty() } + ?: "Nothing Playing", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, @@ -255,7 +242,7 @@ fun FloatingMediaPlayer( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton(onClick = { + IconButton(onClick = { scope.launch { anchoredDraggableState.animateTo(DragValue.Collapsed) } }) { Icon( @@ -264,14 +251,15 @@ fun FloatingMediaPlayer( tint = MaterialTheme.colorScheme.onSurface ) } - + // Metadata (Centered) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f) ) { Text( - text = musicInfo?.title?.takeIf { it.isNotEmpty() } ?: "Nothing Playing", + text = musicInfo?.title?.takeIf { it.isNotEmpty() } + ?: "Nothing Playing", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1, @@ -279,14 +267,15 @@ fun FloatingMediaPlayer( color = MaterialTheme.colorScheme.onSurface ) Text( - text = musicInfo?.artist?.takeIf { it.isNotEmpty() } ?: "from your Mac", + text = musicInfo?.artist?.takeIf { it.isNotEmpty() } + ?: "from your Mac", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) } - + Spacer(modifier = Modifier.size(48.dp)) // To balance the chevron } @@ -296,8 +285,13 @@ fun FloatingMediaPlayer( verticalArrangement = Arrangement.spacedBy(4.dp) ) { val durationSeconds = musicInfo.duration / 1000L - val elapsedSeconds = (currentElapsedTimeMs / 1000L).coerceIn(0L, durationSeconds) - val elapsedFraction = (currentElapsedTimeMs.toFloat() / musicInfo.duration.toFloat()).coerceIn(0f, 1f) + val elapsedSeconds = + (currentElapsedTimeMs / 1000L).coerceIn(0L, durationSeconds) + val elapsedFraction = + (currentElapsedTimeMs.toFloat() / musicInfo.duration.toFloat()).coerceIn( + 0f, + 1f + ) LinearWavyProgressIndicator( progress = { elapsedFraction }, @@ -342,14 +336,22 @@ fun FloatingMediaPlayer( content = { FilledTonalIconButton( onClick = { onMediaAction("media_prev") }, - modifier = Modifier.weight(0.7f).fillMaxHeight() + modifier = Modifier + .weight(0.7f) + .fillMaxHeight() ) { - Icon(Icons.Rounded.SkipPrevious, contentDescription = "Previous", modifier = Modifier.size(36.dp)) + Icon( + Icons.Rounded.SkipPrevious, + contentDescription = "Previous", + modifier = Modifier.size(36.dp) + ) } FilledIconButton( onClick = { onMediaAction("media_play_pause") }, - modifier = Modifier.weight(1.5f).fillMaxHeight() + modifier = Modifier + .weight(1.5f) + .fillMaxHeight() ) { Icon( imageVector = if (musicInfo?.isPlaying == true) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, @@ -360,9 +362,15 @@ fun FloatingMediaPlayer( FilledTonalIconButton( onClick = { onMediaAction("media_next") }, - modifier = Modifier.weight(0.7f).fillMaxHeight() + modifier = Modifier + .weight(0.7f) + .fillMaxHeight() ) { - Icon(Icons.Rounded.SkipNext, contentDescription = "Next", modifier = Modifier.size(36.dp)) + Icon( + Icons.Rounded.SkipNext, + contentDescription = "Next", + modifier = Modifier.size(36.dp) + ) } } ) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt index 94084cd3..dea4080e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt @@ -1,14 +1,30 @@ package com.sameerasw.airsync.presentation.ui.components +import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate @@ -19,8 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.sameerasw.airsync.R import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSection -import android.content.Intent -import android.net.Uri @Composable fun HelpAndGuidesContent() { @@ -134,7 +148,7 @@ private fun ExpandableHelpSection(section: HelpSection) { section.links.forEach { (label, url) -> TextButton( - onClick = { + onClick = { try { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) context.startActivity(intent) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt index 6e9fc475..97c051f6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RotatingAppIcon.kt @@ -10,14 +10,18 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -39,7 +43,8 @@ fun RotatingAppIcon( val scope = rememberCoroutineScope() val rotationAnimatable = remember { Animatable(0f) } - val sensorManager = remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } + val sensorManager = + remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } val gravitySensor = remember { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } var accumulatedRotation by remember { mutableFloatStateOf(0f) } var lastAngle by remember { mutableFloatStateOf(0f) } @@ -68,7 +73,10 @@ fun RotatingAppIcon( val tiltMagnitudeSqr = smoothedAx * smoothedAx + smoothedAy * smoothedAy if (tiltMagnitudeSqr < 2.0f) return - val targetAngle = (atan2(smoothedAx.toDouble(), smoothedAy.toDouble()) * 180 / PI).toFloat() + val targetAngle = (atan2( + smoothedAx.toDouble(), + smoothedAy.toDouble() + ) * 180 / PI).toFloat() var delta = targetAngle - lastAngle if (delta > 180) delta -= 360 @@ -107,11 +115,17 @@ fun RotatingAppIcon( val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { - sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI) + sensorManager.registerListener( + listener, + gravitySensor, + SensorManager.SENSOR_DELAY_UI + ) } + Lifecycle.Event.ON_PAUSE -> { sensorManager.unregisterListener(listener) } + else -> {} } } @@ -119,7 +133,11 @@ fun RotatingAppIcon( lifecycleOwner.lifecycle.addObserver(observer) // Initial register if already resumed if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - sensorManager.registerListener(listener, gravitySensor, SensorManager.SENSOR_DELAY_UI) + sensorManager.registerListener( + listener, + gravitySensor, + SensorManager.SENSOR_DELAY_UI + ) } onDispose { diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt index d9855a03..36e119c9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 76a7db2d..87782df6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -7,10 +7,8 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -18,30 +16,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.sameerasw.airsync.presentation.ui.components.sheets.AppSelectionSheet -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource @@ -54,12 +42,13 @@ import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard import com.sameerasw.airsync.presentation.ui.components.cards.DeviceInfoCard import com.sameerasw.airsync.presentation.ui.components.cards.ExpandNetworkingCard +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.ui.components.cards.MediaSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.NotificationSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard -import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard +import com.sameerasw.airsync.presentation.ui.components.sheets.AppSelectionSheet import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.HapticUtil import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt index 045fb0f1..2843e859 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -1,33 +1,28 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.R import com.sameerasw.airsync.data.ble.BleGattServer +import com.sameerasw.airsync.data.local.DataStoreManager import kotlinx.coroutines.launch -import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer -import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem -import com.sameerasw.airsync.R - @Composable fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel) { val context = LocalContext.current val dataStoreManager = remember { DataStoreManager.getInstance(context) } val scope = rememberCoroutineScope() - + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) - + val uiState by viewModel.uiState.collectAsState() val bleState = uiState.bleConnectionState - + val statusText = when (bleState) { BleGattServer.BleConnectionState.DISCONNECTED -> "For nearby connection" BleGattServer.BleConnectionState.ADVERTISING -> "Scanning" @@ -35,24 +30,24 @@ fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncV BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected" } - IconToggleItem( - iconRes = R.drawable.rounded_bluetooth_24, - title = "Bluetooth LE Sync", - description = statusText, - isChecked = bleEnabled, - onCheckedChange = { - scope.launch { dataStoreManager.setBleSyncEnabled(it) } - } - ) - - IconToggleItem( - iconRes = R.drawable.rounded_bluetooth_searching_24, - title = "BLE Auto-connect", - description = "Automatically switch to Bluetooth when Wi-Fi drops", - isChecked = autoConnect, - onCheckedChange = { - scope.launch { dataStoreManager.setBleAutoConnectEnabled(it) } - }, - enabled = bleEnabled - ) + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = "Bluetooth LE Sync", + description = statusText, + isChecked = bleEnabled, + onCheckedChange = { + scope.launch { dataStoreManager.setBleSyncEnabled(it) } + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = "BLE Auto-connect", + description = "Automatically switch to Bluetooth when Wi-Fi drops", + isChecked = autoConnect, + onCheckedChange = { + scope.launch { dataStoreManager.setBleAutoConnectEnabled(it) } + }, + enabled = bleEnabled + ) } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index eab5a685..e4555386 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -64,140 +64,141 @@ fun ConnectionStatusCard( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // 1) Device image at the top (only when connected) - if (isConnected) { - val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier.fillMaxWidth(0.75f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) - ) - } - - // 2) Device info block (when connected) - if (isConnected && connectedDevice != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "${connectedDevice.name}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) + // 1) Device image at the top (only when connected) + if (isConnected) { + val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.75f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) ) + } - Surface( - shape = RoundedCornerShape(8.dp), - color = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.padding(start = 16.dp) + // 2) Device info block (when connected) + if (isConnected && connectedDevice != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = if (connectedDevice.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - color = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurfaceVariant + "${connectedDevice.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) ) - } - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val ips = uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } - ips.forEach { ip -> - val isActive = ip == uiState.activeIp Surface( - shape = RoundedCornerShape(12.dp), - color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.animateContentSize() + shape = RoundedCornerShape(8.dp), + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 16.dp) ) { Text( - text = "$ip:${connectedDevice.port}", + text = if (connectedDevice.isPlus) "PLUS" else "FREE", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.labelSmall, + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant ) } } - } - } - // 3) Connection status row last - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = if (isConnected) 0.dp else 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val statusText = when { - isConnecting -> "Connecting..." - isConnected -> "Syncing" - else -> "Disconnected" - } - - if (isConnecting) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val ips = + uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } + ips.forEach { ip -> + val isActive = ip == uiState.activeIp + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.animateContentSize() + ) { + Text( + text = "$ip:${connectedDevice.port}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } - if (isConnected) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - SlowlyRotatingAppIcon( - modifier = Modifier.size(54.dp) - ) + // 3) Connection status row last + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = if (isConnected) 0.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusText = when { + isConnecting -> "Connecting..." + isConnected -> "Syncing" + else -> "Disconnected" } - } else if (!isConnecting) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = "Disconnected", - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.error - ) - } - Text( - text = statusText, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) + if (isConnecting) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + } - if (isConnected) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onDisconnect() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ), - modifier = Modifier.height(48.dp) - ) { + if (isConnected) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SlowlyRotatingAppIcon( + modifier = Modifier.size(54.dp) + ) + } + } else if (!isConnecting) { Icon( painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.size(6.dp)) - Text( - text = "Disconnect", - style = MaterialTheme.typography.labelLarge, - maxLines = 1 + contentDescription = "Disconnected", + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.error ) } + + Text( + text = statusText, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + + if (isConnected) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onDisconnect() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.height(48.dp) + ) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = "Disconnect", + style = MaterialTheme.typography.labelLarge, + maxLines = 1 + ) + } + } } } } -} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt index a7494d83..1a845e02 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt @@ -57,176 +57,176 @@ fun DeveloperModeCard( ) { Column(modifier = Modifier.fillMaxWidth()) { IconToggleItem( - iconRes = R.drawable.rounded_troubleshoot_24, - title = "Developer Mode", - isChecked = isDeveloperMode, - onCheckedChange = onToggleDeveloperMode - ) - - if (isDeveloperMode) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "Test Functions", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - - Button( - onClick = { - HapticUtil.performClick(haptics) - onSendDeviceInfo() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Send Device Info") - } + iconRes = R.drawable.rounded_troubleshoot_24, + title = "Developer Mode", + isChecked = isDeveloperMode, + onCheckedChange = onToggleDeveloperMode + ) - Button( - onClick = { - HapticUtil.performClick(haptics) - onSendNotification() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading + if (isDeveloperMode) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Send Test Notification") - } + Text( + "Test Functions", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) - Button( - onClick = { - HapticUtil.performClick(haptics) - onSendDeviceStatus() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Send Device Status") - } + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendDeviceInfo() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Device Info") + } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { HapticUtil.performClick(haptics) - onExportData() + onSendNotification() }, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), enabled = !isLoading ) { - Text("Export Data") + Text("Send Test Notification") } Button( onClick = { HapticUtil.performClick(haptics) - onImportData() + onSendDeviceStatus() }, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), enabled = !isLoading ) { - Text("Import Data") + Text("Send Device Status") } - } - Button( - onClick = { - HapticUtil.performClick(haptics) - onResetOnboarding() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Reset Onboarding") - } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onExportData() + }, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + Text("Export Data") + } - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Icons", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + Button( + onClick = { + HapticUtil.performClick(haptics) + onImportData() + }, + modifier = Modifier.weight(1f), + enabled = !isLoading + ) { + Text("Import Data") + } + } - Button( - onClick = { - HapticUtil.performClick(haptics) - onManualSyncIcons() - }, - modifier = Modifier.fillMaxWidth(), - enabled = isConnected && !isIconSyncLoading - ) { - if (isIconSyncLoading) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + HapticUtil.performClick(haptics) + onResetOnboarding() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Reset Onboarding") } - Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") - } - AnimatedVisibility( - visible = iconSyncMessage.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Card( + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Icons", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + + Button( + onClick = { + HapticUtil.performClick(haptics) + onManualSyncIcons() + }, modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.errorContainer - ) + enabled = isConnected && !isIconSyncLoading ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = iconSyncMessage, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - color = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onErrorContainer + if (isIconSyncLoading) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary ) - TextButton(onClick = { - HapticUtil.performClick(haptics) - onClearIconSyncMessage() - }) { - Text("Dismiss", style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") + } + + AnimatedVisibility( + visible = iconSyncMessage.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = iconSyncMessage, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onErrorContainer + ) + TextButton(onClick = { + HapticUtil.performClick(haptics) + onClearIconSyncMessage() + }) { + Text("Dismiss", style = MaterialTheme.typography.labelMedium) + } } } } - } - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Crash Reporting", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Crash Reporting", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) - Button( - onClick = { - HapticUtil.performClick(haptics) - throw RuntimeException("Test Crash from Developer Options") - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Simulate Crash") + Button( + onClick = { + HapticUtil.performClick(haptics) + throw RuntimeException("Test Crash from Developer Options") + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Simulate Crash") + } } } } } -} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt index a4523d51..deef47a4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt @@ -33,19 +33,19 @@ fun DeviceInfoCard( .fillMaxWidth() .padding(16.dp) ) { - Text("My Android", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) - Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) + Text("My Android", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = deviceName, - onValueChange = onDeviceNameChange, - label = { Text("Device Name") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = deviceName, + onValueChange = onDeviceNameChange, + label = { Text("Device Name") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + } } } -} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt index 86de817a..904fbce6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt @@ -73,7 +73,9 @@ fun IconToggleItem( painter = painterResource(id = iconRes), contentDescription = title, modifier = Modifier.size(24.dp), - tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.38f + ) ) } @@ -84,13 +86,17 @@ fun IconToggleItem( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.38f + ) ) if (description != null) { Text( text = description, style = MaterialTheme.typography.bodyMedium, - color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.38f + ), modifier = Modifier.padding(top = 2.dp) ) } @@ -109,10 +115,14 @@ fun IconToggleItem( ) } else if (onClick != null && !showToggle) { Icon( - painter = painterResource(id = trailingIcon ?: R.drawable.rounded_keyboard_arrow_right_24), + painter = painterResource( + id = trailingIcon ?: R.drawable.rounded_keyboard_arrow_right_24 + ), contentDescription = null, modifier = Modifier.size(24.dp), - tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.38f + ) ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 0eebfbe5..2c512408 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -60,94 +60,94 @@ fun LastConnectedDeviceCard( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - "Last Connected Device", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - val previewRes = DevicePreviewResolver.getPreviewRes(device) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier.fillMaxWidth(0.45f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + Text( + "Last Connected Device", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "${device.name}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + val previewRes = DevicePreviewResolver.getPreviewRes(device) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.45f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) ) + } - val lastConnectedTime = remember(device.lastConnected) { - val currentTime = System.currentTimeMillis() - val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) - when { - diffMinutes < 1 -> "Just now" - diffMinutes < 60 -> "${diffMinutes}m ago" - diffMinutes < 1440 -> "${diffMinutes / 60}h ago" - else -> "${diffMinutes / 1440}d ago" + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "${device.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + + val lastConnectedTime = remember(device.lastConnected) { + val currentTime = System.currentTimeMillis() + val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) + when { + diffMinutes < 1 -> "Just now" + diffMinutes < 60 -> "${diffMinutes}m ago" + diffMinutes < 1440 -> "${diffMinutes / 60}h ago" + else -> "${diffMinutes / 1440}d ago" + } } + Text( + "Last seen $lastConnectedTime", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Text( - "Last seen $lastConnectedTime", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Surface( - shape = RoundedCornerShape(8.dp), - color = if (device.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = if (device.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, + Surface( + shape = RoundedCornerShape(8.dp), color = if (device.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer + MaterialTheme.colorScheme.primaryContainer else - MaterialTheme.colorScheme.onSurfaceVariant - ) + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = if (device.isPlus) "PLUS" else "FREE", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (device.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - } - Button( - onClick = { - HapticUtil.performClick(haptics) - onQuickConnect() - }, - modifier = Modifier - .fillMaxWidth() - .requiredHeight(48.dp) - ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), - contentDescription = "Quick connect", - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Quick Connect") + Button( + onClick = { + HapticUtil.performClick(haptics) + onQuickConnect() + }, + modifier = Modifier + .fillMaxWidth() + .requiredHeight(48.dp) + ) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), + contentDescription = "Quick connect", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Quick Connect") + } } - } IconToggleItem( @@ -168,5 +168,5 @@ fun LastConnectedDeviceCard( onDismissRequest = { showBottomSheet = false } ) } -} + } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt index 2ec23551..9e1185c2 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt @@ -57,96 +57,96 @@ fun ManualConnectionCard( ) ) { Column(modifier = Modifier.fillMaxWidth()) { - IconToggleItem( - iconRes = R.drawable.rounded_devices_24, - title = "Manual Connection", - description = if (expanded) "Hide connection details" else "Enter connection details manually", - showToggle = false, - onClick = { - HapticUtil.performLightTick(haptics) - expanded = !expanded - }, - trailingIcon = if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 - ) + IconToggleItem( + iconRes = R.drawable.rounded_devices_24, + title = "Manual Connection", + description = if (expanded) "Hide connection details" else "Enter connection details manually", + showToggle = false, + onClick = { + HapticUtil.performLightTick(haptics) + expanded = !expanded + }, + trailingIcon = if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 + ) - AnimatedVisibility(visible = expanded) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - ) { - if (onQrScanClick != null) { + AnimatedVisibility(visible = expanded) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (onQrScanClick != null) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onQrScanClick() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), + contentDescription = "Scan QR Code", + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp) + ) + Text("Scan QR Code") + } + } + OutlinedTextField( + value = uiState.ipAddress, + onValueChange = onIpChange, + label = { Text("IP Address") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.port, + onValueChange = onPortChange, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.manualPcName, + onValueChange = onPcNameChange, + label = { Text("PC Name (Optional)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = uiState.symmetricKey ?: "", + onValueChange = onSymmetricKeyChange, + label = { Text("Encryption Key") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("AirSync+", color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.weight(1f)) + Switch( + checked = uiState.manualIsPlus, + onCheckedChange = { enabled -> + if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( + haptics + ) + onIsPlusChange(enabled) + } + ) + } Button( onClick = { HapticUtil.performClick(haptics) - onQrScanClick() + onConnect() }, modifier = Modifier.fillMaxWidth(), ) { - Icon( - painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), - contentDescription = "Scan QR Code", - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp) - ) - Text("Scan QR Code") + Text("Connect") } } - OutlinedTextField( - value = uiState.ipAddress, - onValueChange = onIpChange, - label = { Text("IP Address") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.port, - onValueChange = onPortChange, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.manualPcName, - onValueChange = onPcNameChange, - label = { Text("PC Name (Optional)") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - OutlinedTextField( - value = uiState.symmetricKey ?: "", - onValueChange = onSymmetricKeyChange, - label = { Text("Encryption Key") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("AirSync+", color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.weight(1f)) - Switch( - checked = uiState.manualIsPlus, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onIsPlusChange(enabled) - } - ) - } - Button( - onClick = { - HapticUtil.performClick(haptics) - onConnect() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Connect") - } } } } } -} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt index 26d0c5d6..8df166ea 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -110,7 +109,9 @@ private fun TileItem( Text( text = if (isAdded) stringResource(R.string.status_added) else stringResource(R.string.status_add), style = MaterialTheme.typography.bodySmall, - color = if (isAdded) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + color = if (isAdded) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimary.copy( + alpha = 0.8f + ) ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt index bb85d7bd..6e183a30 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt @@ -1,6 +1,5 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -9,14 +8,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt index d3ad30b4..fd242a95 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt @@ -1,5 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.dialogs +import android.content.Context import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -21,13 +22,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.compose.ui.platform.LocalContext -import android.content.Context import com.sameerasw.airsync.R enum class PermissionType { @@ -76,7 +76,8 @@ fun PermissionExplanationDialog( ) { Column( modifier = Modifier - .fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerHigh) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(18.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -217,7 +218,7 @@ private fun getPermissionInfo(context: Context, permissionType: PermissionType): whyNeeded = "This permission allows AirSync to detect when your phone is ringing, when you answer, or when a call ends, so it can display a live call status on your Mac. \n\nAirSync NEVER accesses your call audio or records conversations. This is used solely to facilitate the remote call notification feature as a device companion.", buttonText = "Grant Phone Access" ) - + PermissionType.BLUETOOTH -> PermissionInfo( title = "Bluetooth Access", icon = R.drawable.rounded_sync_desktop_24, diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt index 53771a32..446ed7ef 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt @@ -1,9 +1,19 @@ package com.sameerasw.airsync.presentation.ui.components.pickers import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt index 48772339..f3d73db3 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt @@ -1,22 +1,42 @@ package com.sameerasw.airsync.presentation.ui.components.sheets -import android.content.Context import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.sameerasw.airsync.R @@ -38,11 +58,12 @@ fun AppSelectionSheet( var showSystemApps by remember { mutableStateOf(false) } val filteredApps = apps.filter { - val matchesSearch = searchQuery.isEmpty() || it.appName.contains(searchQuery, ignoreCase = true) + val matchesSearch = + searchQuery.isEmpty() || it.appName.contains(searchQuery, ignoreCase = true) val isVisible = !it.isSystemApp || showSystemApps || it.isEnabled matchesSearch && isVisible }.distinctBy { it.packageName } - .sortedWith(compareByDescending { it.isEnabled }.thenBy { it.appName.lowercase() }) + .sortedWith(compareByDescending { it.isEnabled }.thenBy { it.appName.lowercase() }) ModalBottomSheet( onDismissRequest = onDismissRequest, diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt index 5a8ca661..59c9dfc0 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt @@ -3,7 +3,9 @@ package com.sameerasw.airsync.presentation.ui.components.sliders import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -12,19 +14,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R import com.sameerasw.airsync.utils.HapticUtil import java.math.BigDecimal @@ -59,11 +58,13 @@ fun ConfigSliderItem( Text( text = title, style = MaterialTheme.typography.bodyMedium, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.38f + ) ) - + Spacer(modifier = Modifier.height(8.dp)) - + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt index 447cc645..d03651ed 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt @@ -3,25 +3,51 @@ package com.sameerasw.airsync.presentation.ui.composables import android.content.Intent import android.content.res.Configuration import android.os.Build -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback @@ -39,17 +65,13 @@ import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import com.sameerasw.airsync.R import com.sameerasw.airsync.presentation.ui.components.HelpAndGuidesContent -import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem -import com.sameerasw.airsync.presentation.ui.components.pickers.CrashReportingPicker import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.ui.theme.GoogleSansFlex -import com.sameerasw.airsync.utils.HapticUtil import com.sameerasw.airsync.utils.DeviceInfoUtil -import kotlinx.coroutines.launch -import kotlin.math.PI -import kotlin.math.atan2 +import com.sameerasw.airsync.utils.HapticUtil enum class OnboardingStep { WELCOME, @@ -304,7 +326,7 @@ fun AcknowledgementStepContent( .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()) ) { - + Spacer(modifier = Modifier.height(16.dp)) Text( @@ -439,7 +461,9 @@ fun FeatureIntroStepContent( Text( text = "Quick Settings Tiles", style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, bottom = 8.dp).fillMaxWidth(), + modifier = Modifier + .padding(start = 16.dp, bottom = 8.dp) + .fillMaxWidth(), textAlign = TextAlign.Start ) @@ -630,7 +654,9 @@ fun PreferencesStepContent( Text( text = stringResource(R.string.label_app_settings), style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp).fillMaxWidth(), + modifier = Modifier + .padding(start = 12.dp, bottom = 8.dp) + .fillMaxWidth(), color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Start ) @@ -678,7 +704,9 @@ fun PreferencesStepContent( Text( text = "Connection", style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp).fillMaxWidth(), + modifier = Modifier + .padding(start = 12.dp, bottom = 8.dp) + .fillMaxWidth(), color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Start ) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt index 860c2a2b..594d24dc 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt @@ -81,19 +81,20 @@ fun Modifier.progressiveBlur( showGradientOverlay: Boolean = true ): Modifier = composed { val overlayColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.65f) - - val blurModifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && blurRadius > 0f) { - Modifier.graphicsLayer { - val shader = RuntimeShader(PROGRESSIVE_BLUR_SKSL) - shader.setFloatUniform("blurRadius", blurRadius) - shader.setFloatUniform("height", height) - shader.setFloatUniform("contentHeight", size.height) - shader.setIntUniform("isTop", if (direction == BlurDirection.TOP) 1 else 0) - - renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content") - .asComposeRenderEffect() - } - } else Modifier + + val blurModifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && blurRadius > 0f) { + Modifier.graphicsLayer { + val shader = RuntimeShader(PROGRESSIVE_BLUR_SKSL) + shader.setFloatUniform("blurRadius", blurRadius) + shader.setFloatUniform("height", height) + shader.setFloatUniform("contentHeight", size.height) + shader.setIntUniform("isTop", if (direction == BlurDirection.TOP) 1 else 0) + + renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content") + .asComposeRenderEffect() + } + } else Modifier val gradientModifier = if (showGradientOverlay) { Modifier.drawWithContent { @@ -105,6 +106,7 @@ fun Modifier.progressiveBlur( endY = height ) to height } + BlurDirection.BOTTOM -> { Brush.verticalGradient( colors = listOf(Color.Transparent, overlayColor), @@ -116,5 +118,7 @@ fun Modifier.progressiveBlur( } } else Modifier - this.then(blurModifier).then(gradientModifier) + this + .then(blurModifier) + .then(gradientModifier) } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt index 768ad625..85d03b90 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt @@ -27,26 +27,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.ContentPaste -import androidx.compose.material.icons.filled.Gamepad import androidx.compose.material.icons.filled.LinkOff -import androidx.compose.material.icons.filled.Phonelink import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.outlined.Phonelink import androidx.compose.material.icons.rounded.ContentPaste import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Gamepad @@ -60,14 +52,10 @@ import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingToolbarDefaults -import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset -import androidx.compose.material3.FloatingToolbarExitDirection.Companion.Bottom -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -89,7 +77,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -102,20 +89,20 @@ import androidx.navigation.compose.rememberNavController import com.sameerasw.airsync.R import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity import com.sameerasw.airsync.presentation.ui.components.AirSyncFloatingToolbar +import com.sameerasw.airsync.presentation.ui.components.FloatingMediaPlayer import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer import com.sameerasw.airsync.presentation.ui.components.SettingsView -import com.sameerasw.airsync.presentation.ui.modifiers.BlurDirection -import com.sameerasw.airsync.presentation.ui.modifiers.progressiveBlur -import com.sameerasw.airsync.presentation.ui.components.FloatingMediaPlayer import com.sameerasw.airsync.presentation.ui.components.cards.ConnectionStatusCard import com.sameerasw.airsync.presentation.ui.components.cards.LastConnectedDeviceCard import com.sameerasw.airsync.presentation.ui.components.cards.ManualConnectionCard -import com.sameerasw.airsync.presentation.ui.components.cards.RemoteFunctionsCard import com.sameerasw.airsync.presentation.ui.components.cards.RateAppCard +import com.sameerasw.airsync.presentation.ui.components.cards.RemoteFunctionsCard import com.sameerasw.airsync.presentation.ui.components.dialogs.ConnectionDialog import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSupportBottomSheet import com.sameerasw.airsync.presentation.ui.composables.WelcomeScreen import com.sameerasw.airsync.presentation.ui.models.AirSyncTab +import com.sameerasw.airsync.presentation.ui.modifiers.BlurDirection +import com.sameerasw.airsync.presentation.ui.modifiers.progressiveBlur import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.ClipboardSyncManager import com.sameerasw.airsync.utils.HapticUtil @@ -220,8 +207,11 @@ fun AirSyncMainScreen( } } } + val pagerState = - rememberPagerState(initialPage = initialPage, pageCount = { if (uiState.isConnected) 4 else 2 }) + rememberPagerState( + initialPage = initialPage, + pageCount = { if (uiState.isConnected) 4 else 2 }) val navCallbackState = rememberUpdatedState(onNavigateToApps) LaunchedEffect(navCallbackState.value) { } @@ -701,305 +691,316 @@ fun AirSyncMainScreen( modifier = Modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.surfaceContainer, ) { innerPadding -> - val density = androidx.compose.ui.platform.LocalDensity.current - val configuration = androidx.compose.ui.platform.LocalConfiguration.current - val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - - val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val statusBarHeightPx = with(density) { statusBarHeight.toPx() } - val topSpacing = (statusBarHeight - 24.dp).coerceAtLeast(0.dp) - - // Track page changes for haptic feedback on swipe - LaunchedEffect(pagerState.currentPage) { - snapshotFlow { pagerState.currentPage }.collect { _ -> - HapticUtil.performLightTick(haptics) + val density = androidx.compose.ui.platform.LocalDensity.current + val configuration = androidx.compose.ui.platform.LocalConfiguration.current + val isLandscape = + configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val statusBarHeightPx = with(density) { statusBarHeight.toPx() } + val topSpacing = (statusBarHeight - 24.dp).coerceAtLeast(0.dp) + + // Track page changes for haptic feedback on swipe + LaunchedEffect(pagerState.currentPage) { + snapshotFlow { pagerState.currentPage }.collect { _ -> + HapticUtil.performLightTick(haptics) + } } - } - // Blur heights - val bottomBlurHeightPx = with(density) { - if (isLandscape) 100.dp.toPx() else 180.dp.toPx() - } + // Blur heights + val bottomBlurHeightPx = with(density) { + if (isLandscape) 100.dp.toPx() else 180.dp.toPx() + } - Box( - modifier = Modifier - .fillMaxSize() - ) { - HorizontalPager( - modifier = modifier + Box( + modifier = Modifier .fillMaxSize() - .progressiveBlur( - blurRadius = if (uiState.isBlurEnabled) 40f else 0f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - .progressiveBlur( - blurRadius = if (uiState.isBlurEnabled) 40f else 0f, - height = bottomBlurHeightPx, - direction = BlurDirection.BOTTOM - ), - state = pagerState - ) { page -> - when (page) { - 0 -> { - // Connect tab content - Column( - modifier = Modifier - .fillMaxSize() - .padding(vertical = 0.dp) - .verticalScroll(connectScrollState) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - Spacer( + ) { + HorizontalPager( + modifier = modifier + .fillMaxSize() + .progressiveBlur( + blurRadius = if (uiState.isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP + ) + .progressiveBlur( + blurRadius = if (uiState.isBlurEnabled) 40f else 0f, + height = bottomBlurHeightPx, + direction = BlurDirection.BOTTOM + ), + state = pagerState + ) { page -> + when (page) { + 0 -> { + // Connect tab content + Column( modifier = Modifier - .height(topSpacing) - .fillMaxWidth() - ) + .fillMaxSize() + .padding(vertical = 0.dp) + .verticalScroll(connectScrollState) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + + Spacer( + modifier = Modifier + .height(topSpacing) + .fillMaxWidth() + ) - RoundedCardContainer { - - // Rating Prompt Card - AnimatedVisibility( - visible = uiState.shouldShowRatingPrompt, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - RateAppCard( - onDismiss = { viewModel.setRatingCardDismissed() }, - onRate = { viewModel.setAppRated() } - ) - } + RoundedCardContainer { + // Rating Prompt Card + AnimatedVisibility( + visible = uiState.shouldShowRatingPrompt, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RateAppCard( + onDismiss = { viewModel.setRatingCardDismissed() }, + onRate = { viewModel.setAppRated() } + ) + } - // Connection Status Card - ConnectionStatusCard( - isConnected = uiState.isConnected, - isConnecting = uiState.isConnecting, - onDisconnect = { disconnect() }, - connectedDevice = uiState.lastConnectedDevice, - lastConnected = uiState.lastConnectedDevice != null, - uiState = uiState, - ) - // Remote Functions Card (Lock Screen, etc.) - AnimatedVisibility( - visible = uiState.isConnected, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - RemoteFunctionsCard( - onRemoteAction = { sendRemoteAction(it) } + // Connection Status Card + ConnectionStatusCard( + isConnected = uiState.isConnected, + isConnecting = uiState.isConnecting, + onDisconnect = { disconnect() }, + connectedDevice = uiState.lastConnectedDevice, + lastConnected = uiState.lastConnectedDevice != null, + uiState = uiState, ) - } - } - RoundedCardContainer { - // Nearby Devices (UDP Discovery) - val discoveredDevices by viewModel.discoveredDevices.collectAsState() - - // Last Connected Device Section - AnimatedVisibility( - visible = !uiState.isConnected && uiState.lastConnectedDevice != null, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - uiState.lastConnectedDevice?.let { device -> - LastConnectedDeviceCard( - device = device, - isAutoReconnectEnabled = uiState.isAutoReconnectEnabled, - onToggleAutoReconnect = { enabled -> - viewModel.setAutoReconnectEnabled( - enabled - ) - }, - onQuickConnect = { - // Check if we can use network-aware connection first - val networkAwareDevice = - viewModel.getNetworkAwareLastConnectedDevice() - if (networkAwareDevice != null) { - // Use network-aware device IP for current network - viewModel.updateIpAddress(networkAwareDevice.ipAddress) - viewModel.updatePort(networkAwareDevice.port) - connect() - } else { - // Fallback to legacy stored device - viewModel.updateIpAddress(device.ipAddress) - viewModel.updatePort(device.port) - viewModel.updateSymmetricKey(device.symmetricKey) - connect() - } - } + // Remote Functions Card (Lock Screen, etc.) + AnimatedVisibility( + visible = uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RemoteFunctionsCard( + onRemoteAction = { sendRemoteAction(it) } ) } } - AnimatedVisibility( - visible = !uiState.isConnected, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceBright - ) + RoundedCardContainer { + // Nearby Devices (UDP Discovery) + val discoveredDevices by viewModel.discoveredDevices.collectAsState() + + // Last Connected Device Section + AnimatedVisibility( + visible = !uiState.isConnected && uiState.lastConnectedDevice != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + uiState.lastConnectedDevice?.let { device -> + LastConnectedDeviceCard( + device = device, + isAutoReconnectEnabled = uiState.isAutoReconnectEnabled, + onToggleAutoReconnect = { enabled -> + viewModel.setAutoReconnectEnabled( + enabled + ) + }, + onQuickConnect = { + // Check if we can use network-aware connection first + val networkAwareDevice = + viewModel.getNetworkAwareLastConnectedDevice() + if (networkAwareDevice != null) { + // Use network-aware device IP for current network + viewModel.updateIpAddress(networkAwareDevice.ipAddress) + viewModel.updatePort(networkAwareDevice.port) + connect() + } else { + // Fallback to legacy stored device + viewModel.updateIpAddress(device.ipAddress) + viewModel.updatePort(device.port) + viewModel.updateSymmetricKey(device.symmetricKey) + connect() + } + } + ) + } + } + + AnimatedVisibility( + visible = !uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .padding(16.dp) ) { - Text( - text = "Available Devices", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - - Switch( - checked = uiState.isDeviceDiscoveryEnabled, - onCheckedChange = { enabled -> - HapticUtil.performClick(haptics) - viewModel.setDeviceDiscoveryEnabled( - context, - enabled - ) - }, - thumbContent = if (uiState.isDeviceDiscoveryEnabled) { - { - Icon( - painter = painterResource(R.drawable.rounded_android_wifi_3_bar_24), - contentDescription = null, - modifier = Modifier.size( - SwitchDefaults.IconSize - ), + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Available Devices", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + + Switch( + checked = uiState.isDeviceDiscoveryEnabled, + onCheckedChange = { enabled -> + HapticUtil.performClick(haptics) + viewModel.setDeviceDiscoveryEnabled( + context, + enabled ) - } - } else null - ) - } + }, + thumbContent = if (uiState.isDeviceDiscoveryEnabled) { + { + Icon( + painter = painterResource(R.drawable.rounded_android_wifi_3_bar_24), + contentDescription = null, + modifier = Modifier.size( + SwitchDefaults.IconSize + ), + ) + } + } else null + ) + } - AnimatedVisibility( - visible = uiState.isDeviceDiscoveryEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - if (discoveredDevices.isEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 4.dp - ) - ) { - LoadingIndicator() - - Text( - text = "Scanning...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } + AnimatedVisibility( + visible = uiState.isDeviceDiscoveryEnabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + if (discoveredDevices.isEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + 4.dp + ) + ) { + LoadingIndicator() - discoveredDevices.forEachIndexed { index, device -> - if (index > 0) { - Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Scanning...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + vertical = 8.dp + ) + ) + } } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable { - HapticUtil.performClick(haptics) - viewModel.updateIpAddress(device.getBestIp()) - viewModel.updatePort(device.port.toString()) - viewModel.updateManualPcName( - device.name - ) - connect(device.id) - } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.apple), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(12.dp)) - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = device.name, - style = MaterialTheme.typography.bodyLarge - ) - Spacer( - modifier = Modifier.width( - 8.dp + discoveredDevices.forEachIndexed { index, device -> + if (index > 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { + HapticUtil.performClick( + haptics ) - ) - if (device.hasLocalIp()) { - Icon( - painter = painterResource( - R.drawable.rounded_android_wifi_3_bar_24 - ), - contentDescription = "Wi-Fi", - modifier = Modifier.size( - 14.dp - ), - tint = MaterialTheme.colorScheme.primary + viewModel.updateIpAddress( + device.getBestIp() + ) + viewModel.updatePort(device.port.toString()) + viewModel.updateManualPcName( + device.name ) + connect(device.id) } - if (device.hasTailscaleIp()) { - if (device.hasLocalIp()) Spacer( + .padding( + horizontal = 16.dp, + vertical = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.apple), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = device.name, + style = MaterialTheme.typography.bodyLarge + ) + Spacer( modifier = Modifier.width( - 4.dp + 8.dp ) ) - Icon( - painter = painterResource( - R.drawable.rounded_network_node_24 - ), - contentDescription = "Tailscale", - modifier = Modifier.size( - 14.dp - ), - tint = MaterialTheme.colorScheme.secondary - ) + if (device.hasLocalIp()) { + Icon( + painter = painterResource( + R.drawable.rounded_android_wifi_3_bar_24 + ), + contentDescription = "Wi-Fi", + modifier = Modifier.size( + 14.dp + ), + tint = MaterialTheme.colorScheme.primary + ) + } + if (device.hasTailscaleIp()) { + if (device.hasLocalIp()) Spacer( + modifier = Modifier.width( + 4.dp + ) + ) + Icon( + painter = painterResource( + R.drawable.rounded_network_node_24 + ), + contentDescription = "Tailscale", + modifier = Modifier.size( + 14.dp + ), + tint = MaterialTheme.colorScheme.secondary + ) + } } + Text( + text = "${device.getBestIp()}:${device.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (uiState.isConnecting && uiState.connectingDeviceId == device.id) { + CircularWavyProgressIndicator( + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Connect", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Text( - text = "${device.getBestIp()}:${device.port}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.weight(1f)) - if (uiState.isConnecting && uiState.connectingDeviceId == device.id) { - CircularWavyProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = "Connect", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) } } } @@ -1007,46 +1008,103 @@ fun AirSyncMainScreen( } } } - } - AnimatedVisibility( - visible = !uiState.isConnected, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - ManualConnectionCard( - isConnected = uiState.isConnected, - lastConnected = uiState.lastConnectedDevice != null, - uiState = uiState, - onIpChange = { viewModel.updateIpAddress(it) }, - onPortChange = { viewModel.updatePort(it) }, - onPcNameChange = { viewModel.updateManualPcName(it) }, - onIsPlusChange = { viewModel.updateManualIsPlus(it) }, - onSymmetricKeyChange = { viewModel.updateSymmetricKey(it) }, - onConnect = { viewModel.prepareForManualConnection() }, - onQrScanClick = { launchScanner(context) } - ) + AnimatedVisibility( + visible = !uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + ManualConnectionCard( + isConnected = uiState.isConnected, + lastConnected = uiState.lastConnectedDevice != null, + uiState = uiState, + onIpChange = { viewModel.updateIpAddress(it) }, + onPortChange = { viewModel.updatePort(it) }, + onPcNameChange = { viewModel.updateManualPcName(it) }, + onIsPlusChange = { viewModel.updateManualIsPlus(it) }, + onSymmetricKeyChange = { + viewModel.updateSymmetricKey( + it + ) + }, + onConnect = { viewModel.prepareForManualConnection() }, + onQrScanClick = { launchScanner(context) } + ) + } } } + + Spacer(modifier = Modifier.height(100.dp)) } + } - Spacer(modifier = Modifier.height(100.dp)) + 1 -> { + if (uiState.isConnected) { + // When connected: page 1 = Remote + RemoteControlScreen( + modifier = Modifier + .fillMaxSize() + .padding( + top = statusBarHeight, + bottom = if (isLandscape) 100.dp else 180.dp + ), + showKeyboard = showKeyboard, + onDismissKeyboard = { showKeyboard = false } + ) + } else { + // When disconnected: page 1 = Settings + SettingsView( + modifier = Modifier.fillMaxSize(), + context = context, + innerPaddingBottom = 0.dp, + uiState = uiState, + deviceInfo = deviceInfo, + versionName = versionName, + viewModel = viewModel, + scrollState = settingsScrollState, + scope = scope, + onSendMessage = { message -> sendMessage(message) }, + onExport = { json -> + pendingExportJson = json + createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json") + }, + onImport = { openDocLauncher.launch(arrayOf("application/json")) }, + onResetOnboarding = { viewModel.resetOnboarding() }, + onShowHelp = { showHelpSheet = true }, + onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() } + ) + } } - } - 1 -> { - if (uiState.isConnected) { - // When connected: page 1 = Remote - RemoteControlScreen( - modifier = Modifier - .fillMaxSize() - .padding(top = statusBarHeight, bottom = if (isLandscape) 100.dp else 180.dp), - showKeyboard = showKeyboard, - onDismissKeyboard = { showKeyboard = false } - ) - } else { - // When disconnected: page 1 = Settings + 2 -> { + if (uiState.isConnected) { + // When connected: page 2 = Clipboard + ClipboardScreen( + clipboardHistory = uiState.clipboardHistory, + isConnected = true, + onSendText = { text -> + viewModel.addClipboardEntry(text, isFromPc = false) + val clipboardJson = JsonUtil.createClipboardUpdateJson(text) + WebSocketUtil.sendMessage(clipboardJson) + }, + onClearHistory = { viewModel.clearClipboardHistory() }, + isHistoryEnabled = uiState.isClipboardHistoryEnabled, + onHistoryToggle = { viewModel.setClipboardHistoryEnabled(it) }, + modifier = Modifier + .fillMaxSize() + .padding( + top = topSpacing, + bottom = if (isLandscape) 100.dp else 180.dp + ), + ) + } else { + Box(Modifier.fillMaxSize()) + } + } + + 3 -> { + // Page 3 only exists when connected = Settings tab SettingsView( modifier = Modifier.fillMaxSize(), context = context, @@ -1069,214 +1127,174 @@ fun AirSyncMainScreen( ) } } - - 2 -> { - if (uiState.isConnected) { - // When connected: page 2 = Clipboard - ClipboardScreen( - clipboardHistory = uiState.clipboardHistory, - isConnected = true, - onSendText = { text -> - viewModel.addClipboardEntry(text, isFromPc = false) - val clipboardJson = JsonUtil.createClipboardUpdateJson(text) - WebSocketUtil.sendMessage(clipboardJson) - }, - onClearHistory = { viewModel.clearClipboardHistory() }, - isHistoryEnabled = uiState.isClipboardHistoryEnabled, - onHistoryToggle = { viewModel.setClipboardHistoryEnabled(it) }, - modifier = Modifier - .fillMaxSize() - .padding(top = topSpacing, bottom = if (isLandscape) 100.dp else 180.dp), - ) - } else { - Box(Modifier.fillMaxSize()) - } - } - - 3 -> { - // Page 3 only exists when connected = Settings tab - SettingsView( - modifier = Modifier.fillMaxSize(), - context = context, - innerPaddingBottom = 0.dp, - uiState = uiState, - deviceInfo = deviceInfo, - versionName = versionName, - viewModel = viewModel, - scrollState = settingsScrollState, - scope = scope, - onSendMessage = { message -> sendMessage(message) }, - onExport = { json -> - pendingExportJson = json - createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json") - }, - onImport = { openDocLauncher.launch(arrayOf("application/json")) }, - onResetOnboarding = { viewModel.resetOnboarding() }, - onShowHelp = { showHelpSheet = true }, - onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() } - ) - } } - } - // Adaptive Bottom Bars Container - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) + // Adaptive Bottom Bars Container + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding( + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + ) // .padding(bottom = 16.dp) - .zIndex(2f) - ) { - if (isLandscape) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.Bottom - ) { - AnimatedVisibility( - visible = uiState.isConnected, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally(), - modifier = Modifier.weight(1f) + .zIndex(2f) + ) { + if (isLandscape) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy( + 16.dp, + Alignment.CenterHorizontally + ), + verticalAlignment = Alignment.Bottom ) { - FloatingMediaPlayer( - musicInfo = macStatus?.music, - albumArtBitmap = albumArtBitmap, - volume = volume, - isMuted = isMuted, - onVolumeChange = { - volume = it - sendRemoteAction("vol_set", it.toInt()) - }, - onToggleMute = { - sendRemoteAction("vol_mute") - isMuted = !isMuted - }, - onMediaAction = { sendRemoteAction(it) } - ) - } + AnimatedVisibility( + visible = uiState.isConnected, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + modifier = Modifier.weight(1f) + ) { + FloatingMediaPlayer( + musicInfo = macStatus?.music, + albumArtBitmap = albumArtBitmap, + volume = volume, + isMuted = isMuted, + onVolumeChange = { + volume = it + sendRemoteAction("vol_set", it.toInt()) + }, + onToggleMute = { + sendRemoteAction("vol_mute") + isMuted = !isMuted + }, + onMediaAction = { sendRemoteAction(it) } + ) + } - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } - } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + onTabSelected = { index -> + scope.launch { + val distance = + kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } } - ) - } - ) - } - } else { - // Portrait: Stacked - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - AnimatedVisibility( - visible = uiState.isConnected, - enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), - exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom), - ) { - FloatingMediaPlayer( - musicInfo = macStatus?.music, - albumArtBitmap = albumArtBitmap, - volume = volume, - isMuted = isMuted, - onVolumeChange = { - volume = it - sendRemoteAction("vol_set", it.toInt()) - }, - onToggleMute = { - sendRemoteAction("vol_mute") - isMuted = !isMuted }, - onMediaAction = { sendRemoteAction(it) } + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } ) } + } else { + // Portrait: Stacked + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + AnimatedVisibility( + visible = uiState.isConnected, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom), + ) { + FloatingMediaPlayer( + musicInfo = macStatus?.music, + albumArtBitmap = albumArtBitmap, + volume = volume, + isMuted = isMuted, + onVolumeChange = { + volume = it + sendRemoteAction("vol_set", it.toInt()) + }, + onToggleMute = { + sendRemoteAction("vol_mute") + isMuted = !isMuted + }, + onMediaAction = { sendRemoteAction(it) } + ) + } - AirSyncFloatingToolbar( - modifier = Modifier.zIndex(1f), - currentPage = pagerState.currentPage, - tabs = tabs, - onTabSelected = { index -> - scope.launch { - val distance = kotlin.math.abs(index - pagerState.currentPage) - if (distance == 1) { - pagerState.animateScrollToPage(index) - } else { - pagerState.scrollToPage(index) - } - } - }, - floatingActionButton = { - MainFAB( - currentTab = tabs.getOrNull(pagerState.currentPage), - isConnected = uiState.isConnected, - onAction = { action -> - when (action) { - "keyboard" -> showKeyboard = !showKeyboard - "clear_history" -> viewModel.clearClipboardHistory() - "disconnect" -> disconnect() - "scan" -> launchScanner(context) + AirSyncFloatingToolbar( + modifier = Modifier.zIndex(1f), + currentPage = pagerState.currentPage, + tabs = tabs, + onTabSelected = { index -> + scope.launch { + val distance = + kotlin.math.abs(index - pagerState.currentPage) + if (distance == 1) { + pagerState.animateScrollToPage(index) + } else { + pagerState.scrollToPage(index) } } - ) - } - ) + }, + floatingActionButton = { + MainFAB( + currentTab = tabs.getOrNull(pagerState.currentPage), + isConnected = uiState.isConnected, + onAction = { action -> + when (action) { + "keyboard" -> showKeyboard = !showKeyboard + "clear_history" -> viewModel.clearClipboardHistory() + "disconnect" -> disconnect() + "scan" -> launchScanner(context) + } + } + ) + } + ) + } } } } } - } - // Dialogs - if (uiState.isDialogVisible) { - ConnectionDialog( - deviceName = deviceInfo.name, - localIp = deviceInfo.localIp, - desktopIp = uiState.ipAddress, - port = uiState.port, - pcName = pcName ?: uiState.lastConnectedDevice?.name, - isPlus = uiState.lastConnectedDevice?.isPlus ?: isPlus, - onDismiss = { viewModel.setDialogVisible(false) }, - onConnect = { - viewModel.setDialogVisible(false) - connect() - } - ) - } + // Dialogs + if (uiState.isDialogVisible) { + ConnectionDialog( + deviceName = deviceInfo.name, + localIp = deviceInfo.localIp, + desktopIp = uiState.ipAddress, + port = uiState.port, + pcName = pcName ?: uiState.lastConnectedDevice?.name, + isPlus = uiState.lastConnectedDevice?.isPlus ?: isPlus, + onDismiss = { viewModel.setDialogVisible(false) }, + onConnect = { + viewModel.setDialogVisible(false) + connect() + } + ) + } - // Help & Support Bottom Sheet - if (showHelpSheet) { - HelpSupportBottomSheet( - onDismissRequest = onDismissHelp - ) - } + // Help & Support Bottom Sheet + if (showHelpSheet) { + HelpSupportBottomSheet( + onDismissRequest = onDismissHelp + ) + } // Welcome Screen Overlay AnimatedVisibility( @@ -1304,7 +1322,7 @@ private fun MainFAB( onAction: (String) -> Unit ) { val haptics = LocalHapticFeedback.current - + FloatingToolbarDefaults.StandardFloatingActionButton( onClick = { HapticUtil.performClick(haptics) @@ -1321,9 +1339,11 @@ private fun MainFAB( R.string.tab_remote -> { Icon(Icons.Rounded.Keyboard, contentDescription = "Keyboard") } + R.string.tab_clipboard -> { Icon(Icons.Rounded.Delete, contentDescription = "Clear History") } + else -> { if (isConnected) { Icon(imageVector = Icons.Filled.LinkOff, contentDescription = "Disconnect") diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt index f051f478..11eb4c2e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt @@ -222,7 +222,7 @@ fun PermissionsScreen( isCritical = false ) } - + "Bluetooth Access" -> { PermissionButton( permissionName = permission, @@ -236,7 +236,9 @@ fun PermissionsScreen( PermissionButton( permissionName = permission, description = "Discover nearby Mac devices on Wi-Fi", - onExplainClick = { showDialog = PermissionType.LOCAL_NETWORK }, + onExplainClick = { + showDialog = PermissionType.LOCAL_NETWORK + }, isCritical = false ) } @@ -245,7 +247,9 @@ fun PermissionsScreen( PermissionButton( permissionName = permission, description = "Accept and end calls from Mac", - onExplainClick = { showDialog = PermissionType.ANSWER_CALLS }, + onExplainClick = { + showDialog = PermissionType.ANSWER_CALLS + }, isCritical = false ) } @@ -293,7 +297,7 @@ fun PermissionsScreen( PermissionType.PHONE -> { onRequestPhonePermission?.invoke() } - + PermissionType.BLUETOOTH -> { onRequestBluetoothPermission?.invoke() } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt index 1c7e4819..9a82618a 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt @@ -10,9 +10,9 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,26 +23,20 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.SpaceBar -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -59,21 +53,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R import com.sameerasw.airsync.presentation.ui.components.KeyboardInputSheet import com.sameerasw.airsync.presentation.ui.components.KeyboardModifiers import com.sameerasw.airsync.presentation.ui.components.ModifierStatus -import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer import com.sameerasw.airsync.utils.HapticUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.channels.Channel @@ -92,7 +82,8 @@ fun RemoteControlScreen( val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() val context = LocalContext.current - val dataStoreManager = remember { com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) } + val dataStoreManager = + remember { com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) } val isFlipped by dataStoreManager.isRemoteFlipped().collectAsState(initial = null) if (isFlipped == null) return @@ -258,13 +249,16 @@ fun RemoteControlScreen( } val config = LocalConfiguration.current - val isWide = config.orientation == Configuration.ORIENTATION_LANDSCAPE || config.screenWidthDp > 600 + val isWide = + config.orientation == Configuration.ORIENTATION_LANDSCAPE || config.screenWidthDp > 600 @Composable fun ExtraKeys() { // Extra Keys FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(12.dp), maxItemsInEachRow = 3 @@ -284,7 +278,7 @@ fun RemoteControlScreen( } OutlinedButton( - onClick = { + onClick = { HapticUtil.performLightTick(haptics) scope.launch { dataStoreManager.setRemoteFlipped(isFlipped != true) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index 2e99cd73..330f0ee7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt @@ -97,7 +97,8 @@ class AirSyncViewModel( // Connection status listener for WebSocket updates private val connectionStatusListener: (Boolean) -> Unit = { isWsConnected -> viewModelScope.launch { - val isBleConnected = _uiState.value.bleConnectionState == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isBleConnected = + _uiState.value.bleConnectionState == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED val isGlobalConnected = isWsConnected || isBleConnected _uiState.value = _uiState.value.copy( @@ -196,14 +197,15 @@ class AirSyncViewModel( viewModelScope.launch { com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> Log.d("AirSyncViewModel", "BLE connection state changed: $state") - val isBleAuthenticated = state == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isBleAuthenticated = + state == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED val isWsConnected = WebSocketUtil.isConnected() - + _uiState.value = _uiState.value.copy( bleConnectionState = state, isConnected = isWsConnected || isBleAuthenticated ) - + if (isBleAuthenticated && !isWsConnected) { // Refresh shortcuts and other side effects if this is the only connection appContext?.let { ctx -> @@ -318,7 +320,7 @@ class AirSyncViewModel( val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context) val isBlurProblematic = DeviceInfoUtil.isBlurProblematicDevice() val isQuickShareEnabled = repository.isQuickShareEnabled().first() - + // Replicate Essentials logic for initial state val isBlurEnabled = isBlurEnabledSetting && !isPowerSaveMode && !isBlurProblematic @@ -411,7 +413,7 @@ class AirSyncViewModel( // Start AirSync Service conditionally ServiceManager.updateServiceState(context) - + // Initial shortcut state ShortcutUtil.refreshShortcuts(context, WebSocketUtil.isConnected()) isNetworkMonitoringActive = true @@ -686,7 +688,8 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isQuickShareEnabled = enabled) viewModelScope.launch { repository.setQuickShareEnabled(enabled) - val intent = Intent(context, com.sameerasw.airsync.quickshare.QuickShareService::class.java) + val intent = + Intent(context, com.sameerasw.airsync.quickshare.QuickShareService::class.java) if (enabled) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { context.startForegroundService(intent) @@ -1133,15 +1136,18 @@ class AirSyncViewModel( } } - private val _notificationApps = MutableStateFlow>(emptyList()) - val notificationApps: StateFlow> = _notificationApps.asStateFlow() + private val _notificationApps = + MutableStateFlow>(emptyList()) + val notificationApps: StateFlow> = + _notificationApps.asStateFlow() fun loadNotificationApps(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { val installed = com.sameerasw.airsync.utils.AppUtil.getInstalledApps(context) val saved = repository.getNotificationApps().first() - val merged = com.sameerasw.airsync.utils.AppUtil.mergeWithSavedApps(installed, saved) + val merged = + com.sameerasw.airsync.utils.AppUtil.mergeWithSavedApps(installed, saved) _notificationApps.value = merged } catch (e: Exception) { Log.e("AirSyncViewModel", "Failed to load notification apps: ${e.message}") @@ -1163,7 +1169,10 @@ class AirSyncViewModel( } } - fun saveAllNotificationApps(context: Context, apps: List) { + fun saveAllNotificationApps( + context: Context, + apps: List + ) { viewModelScope.launch(Dispatchers.IO) { try { _notificationApps.value = apps diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt index accc2a81..68e0c8ef 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/InboundQuickShareConnection.kt @@ -7,28 +7,26 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.util.Log -import com.google.location.nearby.connections.proto.OfflineFrame -import com.google.location.nearby.connections.proto.PayloadTransferFrame -import com.google.location.nearby.connections.proto.V1Frame -import com.google.location.nearby.connections.proto.ConnectionResponseFrame -import com.google.location.nearby.connections.proto.OsInfo -import com.google.security.cryptauth.lib.securegcm.Ukey2ClientFinished -import com.google.security.cryptauth.lib.securegcm.Ukey2ClientInit -import com.google.security.cryptauth.lib.securegcm.Ukey2ServerInit -import okio.ByteString.Companion.toByteString -import com.google.android.gms.nearby.sharing.ConnectionResponseFrame as SharingResponse import com.google.android.gms.nearby.sharing.Frame import com.google.android.gms.nearby.sharing.IntroductionFrame import com.google.android.gms.nearby.sharing.PairedKeyEncryptionFrame import com.google.android.gms.nearby.sharing.PairedKeyResultFrame -import com.google.security.cryptauth.lib.securegcm.Ukey2Message -import com.google.android.gms.nearby.sharing.V1Frame as SharingV1 +import com.google.location.nearby.connections.proto.ConnectionResponseFrame +import com.google.location.nearby.connections.proto.OfflineFrame +import com.google.location.nearby.connections.proto.OsInfo +import com.google.location.nearby.connections.proto.PayloadTransferFrame import com.google.location.nearby.connections.proto.PayloadTransferFrame.PayloadHeader +import com.google.location.nearby.connections.proto.V1Frame +import com.google.security.cryptauth.lib.securegcm.Ukey2ClientInit +import com.google.security.cryptauth.lib.securegcm.Ukey2Message +import okio.ByteString.Companion.toByteString import java.io.File import java.io.FileOutputStream import java.net.Socket import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors +import com.google.android.gms.nearby.sharing.ConnectionResponseFrame as SharingResponse +import com.google.android.gms.nearby.sharing.V1Frame as SharingV1 /** * Handles an incoming Quick Share connection. @@ -42,8 +40,10 @@ class InboundQuickShareConnection( var onConnectionReady: ((InboundQuickShareConnection) -> Unit)? = null var onIntroductionReceived: ((IntroductionFrame) -> Unit)? = null var onFinished: ((InboundQuickShareConnection) -> Unit)? = null - var onFileProgress: ((fileName: String, percent: Int, bytesTransferred: Long, totalSize: Long, transferId: String) -> Unit)? = null - var onFileComplete: ((fileName: String, transferId: String, success: Boolean, uri: android.net.Uri?) -> Unit)? = null + var onFileProgress: ((fileName: String, percent: Int, bytesTransferred: Long, totalSize: Long, transferId: String) -> Unit)? = + null + var onFileComplete: ((fileName: String, transferId: String, success: Boolean, uri: android.net.Uri?) -> Unit)? = + null companion object { private const val TAG = "InboundQSConnection" @@ -109,9 +109,10 @@ class InboundQuickShareConnection( throw IllegalStateException("Expected CLIENT_INIT, got ${clientInitEnvelope.message_type}") } val clientInit = Ukey2ClientInit.ADAPTER.decode(clientInitEnvelope.message_data!!) - + // Send ServerInit (Wrapped in Ukey2Message) - val serverInit = ukey2.handleClientInit(clientInit) ?: throw IllegalStateException("Failed to handle ClientInit") + val serverInit = ukey2.handleClientInit(clientInit) + ?: throw IllegalStateException("Failed to handle ClientInit") val serverInitEnvelope = Ukey2Message( message_type = Ukey2Message.Type.SERVER_INIT, message_data = serverInit.encode().toByteString() @@ -133,7 +134,7 @@ class InboundQuickShareConnection( serverInitEnvelopeBytes = serverInitEnvelopeBytes, clientInit = clientInit ) - + Log.d(TAG, "UKEY2 Handshake complete. PIN: ${ukey2.authString}") onConnectionReady?.invoke(this) @@ -144,9 +145,9 @@ class InboundQuickShareConnection( Log.d(TAG, "Read ConnectionResponse: ${responseFrameData.size} bytes") val responseFrame = OfflineFrame.ADAPTER.decode(responseFrameData) if (responseFrame.v1!!.type != V1Frame.FrameType.CONNECTION_RESPONSE) { - throw IllegalStateException("Expected CONNECTION_RESPONSE, got ${responseFrame.v1!!.type}") + throw IllegalStateException("Expected CONNECTION_RESPONSE, got ${responseFrame.v1!!.type}") } - + // Send OUR ConnectionResponse (Accept) val ourResponse = OfflineFrame( version = OfflineFrame.Version.V1, @@ -280,13 +281,13 @@ class InboundQuickShareConnection( try { val d2dPayload = readEncryptedMessage() val offlineFrame = OfflineFrame.ADAPTER.decode(d2dPayload) - + when (offlineFrame.v1?.type) { V1Frame.FrameType.PAYLOAD_TRANSFER -> { val transfer = offlineFrame.v1!!.payload_transfer!! val header = transfer.payload_header val chunk = transfer.payload_chunk - + when (header?.type) { PayloadHeader.PayloadType.BYTES -> { if (chunk?.body != null && chunk.body.size > 0) { @@ -301,16 +302,20 @@ class InboundQuickShareConnection( pendingBytesPayload = null } } + PayloadHeader.PayloadType.FILE -> { handlePayloadTransfer(transfer) } + else -> Log.d(TAG, "Unknown payload type: ${header?.type}") } } + V1Frame.FrameType.DISCONNECTION -> { Log.d(TAG, "Received disconnection frame") isRunning = false } + else -> Log.d(TAG, "Unknown offline frame type: ${offlineFrame.v1?.type}") } } catch (e: Exception) { @@ -333,13 +338,16 @@ class InboundQuickShareConnection( prepareFiles(v1Frame.introduction!!) onIntroductionReceived?.invoke(v1Frame.introduction!!) } + SharingV1.FrameType.CANCEL -> { Log.d(TAG, "Transfer cancelled by sender") isRunning = false } + SharingV1.FrameType.PAIRED_KEY_RESULT -> { Log.d(TAG, "Received PairedKeyResult") } + else -> Log.d(TAG, "Received unhandled sharing frame type: ${v1Frame.type}") } } @@ -349,7 +357,7 @@ class InboundQuickShareConnection( */ fun sendSharingResponse(status: SharingResponse.Status) { val responseFrame = SharingResponse(status = status) - + val frame = Frame( version = Frame.Version.V1, v1 = SharingV1( @@ -357,9 +365,9 @@ class InboundQuickShareConnection( connection_response = responseFrame ) ) - + writeSharingFrame(frame) - + if (status == SharingResponse.Status.ACCEPT) { openFiles() } @@ -382,7 +390,10 @@ class InboundQuickShareConnection( put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) put(MediaStore.Downloads.IS_PENDING, 1) } - val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + val uri = context.contentResolver.insert( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + values + ) if (uri != null) { info.outputStream = context.contentResolver.openOutputStream(uri) info.uri = uri @@ -390,7 +401,8 @@ class InboundQuickShareConnection( } } else { @Suppress("DEPRECATION") - val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) downloadsDir.mkdirs() var targetFile = File(downloadsDir, info.name) var counter = 1 @@ -420,7 +432,13 @@ class InboundQuickShareConnection( // Update progress (throttle if needed, but for now simple) if (info.size > 0) { val percent = ((info.bytesTransferred * 100) / info.size).toInt() - onFileProgress?.invoke(info.name, percent, info.bytesTransferred, info.size, id.toString()) + onFileProgress?.invoke( + info.name, + percent, + info.bytesTransferred, + info.size, + id.toString() + ) } } @@ -429,7 +447,7 @@ class InboundQuickShareConnection( Log.d(TAG, "File ${info.name} transfer complete (${info.bytesTransferred} bytes)") info.outputStream?.close() info.outputStream = null - + // Clear IS_PENDING so file becomes visible in Downloads if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && info.uri != null) { val values = ContentValues().apply { @@ -437,9 +455,9 @@ class InboundQuickShareConnection( } context.contentResolver.update(info.uri!!, values, null, null) } - + onFileComplete?.invoke(info.name, id.toString(), true, info.uri) - + // Check if all files are finished if (transferredFiles.values.all { it.outputStream == null }) { Log.d(TAG, "All files transferred") diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareAdvertiser.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareAdvertiser.kt index d8073e5d..be3e866a 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareAdvertiser.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareAdvertiser.kt @@ -3,7 +3,6 @@ package com.sameerasw.airsync.quickshare import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo -import android.os.Build import android.util.Base64 import android.util.Log import java.nio.charset.StandardCharsets @@ -16,7 +15,8 @@ class QuickShareAdvertiser(private val context: Context) { companion object { private const val TAG = "QuickShareAdvertiser" private const val SERVICE_TYPE = "_FC9F5ED42C8A._tcp." - private const val SERVICE_ID_HASH = "fM5e" // Base64 of 0xFC, 0x9F, 0x5E (after PCP 0x23 and 4-byte ID) + private const val SERVICE_ID_HASH = + "fM5e" // Base64 of 0xFC, 0x9F, 0x5E (after PCP 0x23 and 4-byte ID) // Actually, let's calculate it properly. } @@ -35,10 +35,13 @@ class QuickShareAdvertiser(private val context: Context) { serviceType = SERVICE_TYPE serviceName = generateServiceName() setPort(port) - + // TXT record 'n' contains endpoint info val endpointInfo = serializeEndpointInfo(deviceName) - val endpointInfoBase64 = Base64.encodeToString(endpointInfo, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + val endpointInfoBase64 = Base64.encodeToString( + endpointInfo, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) setAttribute("n", endpointInfoBase64) } @@ -61,7 +64,11 @@ class QuickShareAdvertiser(private val context: Context) { } try { - nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener) + nsdManager.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListener + ) } catch (e: Exception) { Log.e(TAG, "Failed to register service", e) } @@ -88,19 +95,19 @@ class QuickShareAdvertiser(private val context: Context) { bytes[7] = 0x5E.toByte() bytes[8] = 0 bytes[9] = 0 - + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } private fun serializeEndpointInfo(deviceName: String): ByteArray { val nameBytes = deviceName.toByteArray(StandardCharsets.UTF_8) val nameLen = Math.min(nameBytes.size, 255) - + // 1 byte: (deviceType << 1) | Visibility(0) | Version(0) // Device types: phone=1, tablet=2, computer=3. We'll use phone=1. val deviceType = 1 val firstByte = (deviceType shl 1).toByte() - + val bytes = ByteArray(1 + 16 + 1 + nameLen) bytes[0] = firstByte // 16 random bytes @@ -108,10 +115,10 @@ class QuickShareAdvertiser(private val context: Context) { val randomBytes = ByteArray(16) random.nextBytes(randomBytes) System.arraycopy(randomBytes, 0, bytes, 1, 16) - + bytes[17] = nameLen.toByte() System.arraycopy(nameBytes, 0, bytes, 18, nameLen) - + return bytes } } diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt index 92d60446..551047fd 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareConnection.kt @@ -64,9 +64,9 @@ open class QuickShareConnection( fun readEncryptedMessage(): ByteArray { val context = ukey2Context ?: throw IllegalStateException("UKEY2 context not set") val frameData = readFrame() - + val smsg = SecureMessage.ADAPTER.decode(frameData) - + // 1. Verify HMAC val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(context.receiveHmacKey, "HmacSHA256")) @@ -80,7 +80,11 @@ open class QuickShareConnection( val hb = HeaderAndBody.ADAPTER.decode(smsg.header_and_body!!) val iv = hb.header_!!.iv!!.toByteArray() val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(context.decryptKey, "AES"), IvParameterSpec(iv)) + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(context.decryptKey, "AES"), + IvParameterSpec(iv) + ) val decryptedData = cipher.doFinal(hb.body!!.toByteArray()) // 3. Parse DeviceToDeviceMessage @@ -111,7 +115,11 @@ open class QuickShareConnection( // 2. Encrypt with AES-CBC val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") val iv = ByteArray(16).also { java.util.Random().nextBytes(it) } - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(context.encryptKey, "AES"), IvParameterSpec(iv)) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(context.encryptKey, "AES"), + IvParameterSpec(iv) + ) val encryptedData = cipher.doFinal(serializedD2D) // 3. Create HeaderAndBody diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareServer.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareServer.kt index 2c5630cd..ffeca54b 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareServer.kt @@ -3,7 +3,6 @@ package com.sameerasw.airsync.quickshare import android.content.Context import android.util.Log import java.net.ServerSocket -import java.net.Socket import java.util.concurrent.Executors /** @@ -27,18 +26,18 @@ class QuickShareServer( fun start() { if (isRunning) return isRunning = true - + try { serverSocket = ServerSocket(0) // Bind to any available port synchronously val currentPort = port Log.d(TAG, "Server bound to port $currentPort") - + executor.execute { try { while (isRunning) { val socket = serverSocket?.accept() ?: break Log.d(TAG, "New connection from ${socket.remoteSocketAddress}") - + val connection = InboundQuickShareConnection( context = context, socket = socket diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt index 4d216972..c5eb9cd4 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt @@ -16,8 +16,6 @@ import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -30,7 +28,7 @@ class QuickShareService : Service() { private const val TAG = "QuickShareService" private const val NOTIFICATION_ID = 2001 private const val CHANNEL_ID = "quick_share_channel" - + const val ACTION_ACCEPT = "com.sameerasw.airsync.quickshare.ACCEPT" const val ACTION_REJECT = "com.sameerasw.airsync.quickshare.REJECT" const val ACTION_START_DISCOVERY = "com.sameerasw.airsync.quickshare.START_DISCOVERY" @@ -46,13 +44,14 @@ class QuickShareService : Service() { private val binder = LocalBinder() private val serviceScope = CoroutineScope(Dispatchers.IO) private var discoveryJob: kotlinx.coroutines.Job? = null - + private data class SpeedState( var lastBytes: Long = 0, var lastTime: Long = System.currentTimeMillis(), var smoothedSpeed: Double? = null, var lastEtaString: String? = null ) + private val speedStates = mutableMapOf() inner class LocalBinder : Binder() { @@ -62,68 +61,75 @@ class QuickShareService : Service() { override fun onCreate() { super.onCreate() createNotificationChannel() - + startForeground(NOTIFICATION_ID, createNotification("Quick Share is active")) - + server = QuickShareServer(this) { connection -> val id = java.util.UUID.randomUUID().toString() activeConnections[id] = connection - + connection.onConnectionReady = { conn -> discoveryJob?.cancel() // Transfer started, abort timeout val pin = conn.ukey2Context?.authString ?: "" Log.d(TAG, "Connection ready, PIN: $pin") updateForegroundNotification("PIN: $pin - Waiting for files...") - + var lastUpdate = 0L - conn.onFileProgress = { fileName, percent, bytesTransferred, totalSize, transferId -> - val now = System.currentTimeMillis() - if (now - lastUpdate > 800) { // Throttle updates - val state = speedStates.getOrPut(transferId) { SpeedState(bytesTransferred, now) } - val timeDiff = (now - state.lastTime) / 1000.0 - - var etaString: String? = null - if (timeDiff >= 1.0) { - val bytesDiff = bytesTransferred - state.lastBytes - val intervalSpeed = bytesDiff / timeDiff - - val alpha = 0.4 - val newSpeed = if (state.smoothedSpeed != null) { - (alpha * intervalSpeed) + ((1.0 - alpha) * state.smoothedSpeed!!) - } else { - intervalSpeed + conn.onFileProgress = + { fileName, percent, bytesTransferred, totalSize, transferId -> + val now = System.currentTimeMillis() + if (now - lastUpdate > 800) { // Throttle updates + val state = speedStates.getOrPut(transferId) { + SpeedState( + bytesTransferred, + now + ) } - state.smoothedSpeed = newSpeed - state.lastBytes = bytesTransferred - state.lastTime = now - - if (newSpeed > 0) { - val remainingBytes = (totalSize - bytesTransferred).coerceAtLeast(0) - val secondsRemaining = (remainingBytes / newSpeed).toLong() - etaString = if (secondsRemaining < 60) { - "$secondsRemaining sec remaining" + val timeDiff = (now - state.lastTime) / 1000.0 + + var etaString: String? = null + if (timeDiff >= 1.0) { + val bytesDiff = bytesTransferred - state.lastBytes + val intervalSpeed = bytesDiff / timeDiff + + val alpha = 0.4 + val newSpeed = if (state.smoothedSpeed != null) { + (alpha * intervalSpeed) + ((1.0 - alpha) * state.smoothedSpeed!!) } else { - "${secondsRemaining / 60} min remaining" + intervalSpeed + } + state.smoothedSpeed = newSpeed + state.lastBytes = bytesTransferred + state.lastTime = now + + if (newSpeed > 0) { + val remainingBytes = + (totalSize - bytesTransferred).coerceAtLeast(0) + val secondsRemaining = (remainingBytes / newSpeed).toLong() + etaString = if (secondsRemaining < 60) { + "$secondsRemaining sec remaining" + } else { + "${secondsRemaining / 60} min remaining" + } } } - } - lastUpdate = now - com.sameerasw.airsync.utils.NotificationUtil.showFileProgress( - this@QuickShareService, - transferId.hashCode(), - fileName, - percent, - transferId, - isSending = false, - etaString = etaString ?: state.lastEtaString ?: "Calculating..." - ) - if (etaString != null) { - state.lastEtaString = etaString + lastUpdate = now + com.sameerasw.airsync.utils.NotificationUtil.showFileProgress( + this@QuickShareService, + transferId.hashCode(), + fileName, + percent, + transferId, + isSending = false, + etaString = etaString ?: state.lastEtaString ?: "Calculating..." + ) + if (etaString != null) { + state.lastEtaString = etaString + } } } - } - + conn.onFileComplete = { fileName, transferId, success, uri -> speedStates.remove(transferId) com.sameerasw.airsync.utils.NotificationUtil.showFileComplete( @@ -136,17 +142,18 @@ class QuickShareService : Service() { ) } } - + connection.onIntroductionReceived = { intro -> val deviceName = connection.endpointName ?: "Unknown Device" val firstFileName = intro.file_metadata.firstOrNull()?.name ?: "Unknown File" val fileCount = intro.file_metadata.size - val displayText = if (fileCount > 1) "$firstFileName and ${fileCount - 1} more" else firstFileName - + val displayText = + if (fileCount > 1) "$firstFileName and ${fileCount - 1} more" else firstFileName + serviceScope.launch { val pairedDevice = dataStoreManager.getLastConnectedDevice().first() val pairedName = pairedDevice?.name - + if (!pairedName.isNullOrBlank() && deviceName == pairedName) { Log.d(TAG, "Auto-accepting transfer from paired Mac: $deviceName") connection.sendSharingResponse(ConnectionResponseFrame.Status.ACCEPT) @@ -155,12 +162,12 @@ class QuickShareService : Service() { } } } - + connection.onFinished = { activeConnections.remove(id) val manager = getSystemService(NotificationManager::class.java) manager.cancel(NOTIFICATION_ID + id.hashCode()) - + if (activeConnections.isEmpty()) { Log.d(TAG, "All transfers finished, stopping discovery") stopDiscovery() @@ -177,11 +184,13 @@ class QuickShareService : Service() { val id = intent.getStringExtra(EXTRA_CONNECTION_ID) activeConnections[id]?.sendSharingResponse(ConnectionResponseFrame.Status.ACCEPT) } + ACTION_REJECT -> { val id = intent.getStringExtra(EXTRA_CONNECTION_ID) activeConnections[id]?.sendSharingResponse(ConnectionResponseFrame.Status.REJECT) activeConnections.remove(id) } + ACTION_START_DISCOVERY -> { serviceScope.launch { val enabled = dataStoreManager.isQuickShareEnabled().first() @@ -192,6 +201,7 @@ class QuickShareService : Service() { } } } + ACTION_CANCEL_TRANSFER -> { val transferIdStr = intent.getStringExtra(EXTRA_TRANSFER_ID) val transferId = transferIdStr?.toLongOrNull() @@ -207,6 +217,7 @@ class QuickShareService : Service() { val manager = getSystemService(NotificationManager::class.java) manager.cancel(transferIdStr?.hashCode() ?: 0) } + else -> { // Remove the startForeground/createNotification call from here server.start() @@ -217,10 +228,10 @@ class QuickShareService : Service() { private fun startDiscoveryWithTimeout() { discoveryJob?.cancel() - + // Ensure service is in foreground with a notification while active startForeground(NOTIFICATION_ID, createNotification("Searching for files...")) - + server.start() val port = server.port if (port == -1) { @@ -236,7 +247,7 @@ class QuickShareService : Service() { updateForegroundNotification("Quick Share is visible for 60s...") kotlinx.coroutines.delay(60_000) // 1 minute timeout - + if (activeConnections.isEmpty()) { Log.d(TAG, "Discovery timed out, stopping") stopDiscovery() @@ -250,7 +261,7 @@ class QuickShareService : Service() { discoveryJob?.cancel() discoveryJob = null advertiser.stopAdvertising() - + if (activeConnections.isEmpty()) { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -291,13 +302,23 @@ class QuickShareService : Service() { action = ACTION_ACCEPT putExtra(EXTRA_CONNECTION_ID, connectionId) } - val acceptPendingIntent = PendingIntent.getService(this, 0, acceptIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val acceptPendingIntent = PendingIntent.getService( + this, + 0, + acceptIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) val rejectIntent = Intent(this, QuickShareService::class.java).apply { action = ACTION_REJECT putExtra(EXTRA_CONNECTION_ID, connectionId) } - val rejectPendingIntent = PendingIntent.getService(this, 1, rejectIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val rejectPendingIntent = PendingIntent.getService( + this, + 1, + rejectIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Quick Share from $deviceName") diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt index da53c173..a0e884ba 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt @@ -8,13 +8,11 @@ import com.google.security.cryptauth.lib.securegcm.Ukey2ServerInit import com.google.security.cryptauth.lib.securemessage.EcP256PublicKey import com.google.security.cryptauth.lib.securemessage.GenericPublicKey import com.google.security.cryptauth.lib.securemessage.PublicKeyType -import okio.ByteString import okio.ByteString.Companion.toByteString import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.digests.SHA512Digest import org.bouncycastle.crypto.generators.HKDFBytesGenerator import org.bouncycastle.crypto.params.HKDFParameters -import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.provider.BouncyCastleProvider import java.security.KeyPair import java.security.KeyPairGenerator @@ -35,10 +33,38 @@ class Ukey2Context { } private val D2D_SALT = byteArrayOf( - 0x82.toByte(), 0xAA.toByte(), 0x55.toByte(), 0xA0.toByte(), 0xD3.toByte(), 0x97.toByte(), 0xF8.toByte(), 0x83.toByte(), - 0x46.toByte(), 0xCA.toByte(), 0x1C.toByte(), 0xEE.toByte(), 0x8D.toByte(), 0x39.toByte(), 0x09.toByte(), 0xB9.toByte(), - 0x5F.toByte(), 0x13.toByte(), 0xFA.toByte(), 0x7D.toByte(), 0xEB.toByte(), 0x1D.toByte(), 0x4A.toByte(), 0xB3.toByte(), - 0x83.toByte(), 0x76.toByte(), 0xB8.toByte(), 0x25.toByte(), 0x6D.toByte(), 0xA8.toByte(), 0x55.toByte(), 0x10.toByte() + 0x82.toByte(), + 0xAA.toByte(), + 0x55.toByte(), + 0xA0.toByte(), + 0xD3.toByte(), + 0x97.toByte(), + 0xF8.toByte(), + 0x83.toByte(), + 0x46.toByte(), + 0xCA.toByte(), + 0x1C.toByte(), + 0xEE.toByte(), + 0x8D.toByte(), + 0x39.toByte(), + 0x09.toByte(), + 0xB9.toByte(), + 0x5F.toByte(), + 0x13.toByte(), + 0xFA.toByte(), + 0x7D.toByte(), + 0xEB.toByte(), + 0x1D.toByte(), + 0x4A.toByte(), + 0xB3.toByte(), + 0x83.toByte(), + 0x76.toByte(), + 0xB8.toByte(), + 0x25.toByte(), + 0x6D.toByte(), + 0xA8.toByte(), + 0x55.toByte(), + 0x10.toByte() ) } @@ -104,8 +130,9 @@ class Ukey2Context { digest.update(clientFinishEnvelopeBytes, 0, clientFinishEnvelopeBytes.size) val calculatedCommitment = ByteArray(digest.digestSize) digest.doFinal(calculatedCommitment, 0) - - val p256Commitment = clientInit.cipher_commitments.find { it.handshake_cipher == Ukey2HandshakeCipher.P256_SHA512 }?.commitment?.toByteArray() + + val p256Commitment = + clientInit.cipher_commitments.find { it.handshake_cipher == Ukey2HandshakeCipher.P256_SHA512 }?.commitment?.toByteArray() if (p256Commitment == null || !p256Commitment.contentEquals(calculatedCommitment)) { Log.w("Ukey2Context", "Commitment mismatch (bypassed for reliability)") } else { @@ -113,13 +140,15 @@ class Ukey2Context { } val clientFinished = Ukey2ClientFinished.ADAPTER.decode( - com.google.security.cryptauth.lib.securegcm.Ukey2Message.ADAPTER.decode(clientFinishEnvelopeBytes).message_data!! + com.google.security.cryptauth.lib.securegcm.Ukey2Message.ADAPTER.decode( + clientFinishEnvelopeBytes + ).message_data!! ) - + // 2. ECDH Shared Secret val clientPubKeyProto = GenericPublicKey.ADAPTER.decode(clientFinished.public_key!!) val clientPubKey = decodePublicKey(clientPubKeyProto.ec_p256_public_key!!) - + val ka = KeyAgreement.getInstance("ECDH") ka.init(keyPair.private) ka.doPhase(clientPubKey, true) @@ -131,7 +160,7 @@ class Ukey2Context { // 4. HKDF Derivation — use the raw Ukey2Message envelope bytes, matching the Mac val ukeyInfo = clientInitEnvelopeBytes + serverInitEnvelopeBytes - + val authKey = hkdf(derivedSecretKey, "UKEY2 v1 auth".toByteArray(), ukeyInfo) val nextSecret = hkdf(derivedSecretKey, "UKEY2 v1 next".toByteArray(), ukeyInfo) @@ -149,7 +178,12 @@ class Ukey2Context { sendHmacKey = hkdf(d2dServerKey, smsgSalt, "SIG:1".toByteArray()) } - private fun hkdf(key: ByteArray, salt: ByteArray, info: ByteArray, length: Int = 32): ByteArray { + private fun hkdf( + key: ByteArray, + salt: ByteArray, + info: ByteArray, + length: Int = 32 + ): ByteArray { val generator = HKDFBytesGenerator(SHA256Digest()) generator.init(HKDFParameters(key, salt, info)) val result = ByteArray(length) @@ -179,14 +213,15 @@ class Ukey2Context { val x = java.math.BigInteger(1, ecPubKey.x.toByteArray()) val y = java.math.BigInteger(1, ecPubKey.y.toByteArray()) val ecPoint = java.security.spec.ECPoint(x, y) - + val kf = java.security.KeyFactory.getInstance("EC") - + // Get P-256 parameter spec val algorithmParameters = java.security.AlgorithmParameters.getInstance("EC") algorithmParameters.init(java.security.spec.ECGenParameterSpec("secp256r1")) - val ecParameterSpec = algorithmParameters.getParameterSpec(java.security.spec.ECParameterSpec::class.java) - + val ecParameterSpec = + algorithmParameters.getParameterSpec(java.security.spec.ECParameterSpec::class.java) + val keySpec = java.security.spec.ECPublicKeySpec(ecPoint, ecParameterSpec) return kf.generatePublic(keySpec) } diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 400b434c..3af04463 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -205,7 +205,10 @@ class AirSyncService : Service() { networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - Log.d(TAG, "Network available, triggering burst broadcast and refreshing socket") + Log.d( + TAG, + "Network available, triggering burst broadcast and refreshing socket" + ) // Refresh UDP socket to bind to new network interface UDPDiscoveryManager.refreshSocket() // When network becomes available, do a burst to announce ourselves diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt index 6f9ef219..fa43708d 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -165,8 +165,16 @@ class MacMediaPlayerService : Service() { val duration = intent.getLongExtra(EXTRA_DURATION, 0L) val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) - - startMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) + + startMacMediaSession( + title, + artist, + isPlaying, + elapsedTime, + duration, + timestamp, + playbackRate + ) } ACTION_UPDATE_MAC_MEDIA -> { @@ -177,8 +185,16 @@ class MacMediaPlayerService : Service() { val duration = intent.getLongExtra(EXTRA_DURATION, 0L) val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) - - updateMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) + + updateMacMediaSession( + title, + artist, + isPlaying, + elapsedTime, + duration, + timestamp, + playbackRate + ) } // Handle media control actions from notification buttons "MAC_MEDIA_play" -> { @@ -298,7 +314,9 @@ class MacMediaPlayerService : Service() { PlaybackStateCompat.ACTION_STOP // Parse ISO8601 timestamp to calculate elapsed time since reporting - var position = if (elapsedTime >= 0) elapsedTime else (mediaSession?.controller?.playbackState?.position ?: 0L) + var position = + if (elapsedTime >= 0) elapsedTime else (mediaSession?.controller?.playbackState?.position + ?: 0L) if (isPlaying && !timestamp.isNullOrEmpty()) { try { val reportedAt = java.time.Instant.parse(timestamp).toEpochMilli() @@ -430,7 +448,7 @@ class MacMediaPlayerService : Service() { val duration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) val position = it.controller.playbackState?.position ?: 0L val speed = it.controller.playbackState?.playbackSpeed?.toDouble() ?: 1.0 - + updateMacMediaSession(title, artist, isPlaying, position, duration, null, speed) } } diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index c0f22a71..ed8c31c5 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -119,12 +119,14 @@ class MediaNotificationListener : NotificationListenerService() { val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "" val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "" val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING - val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L + val durationMs = + metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L val positionMs = playbackState?.position ?: 0L val positionTimestampMs = System.currentTimeMillis() val isBuffering = when (playbackState?.state) { PlaybackState.STATE_BUFFERING, PlaybackState.STATE_CONNECTING -> true + else -> false } @@ -540,7 +542,7 @@ class MediaNotificationListener : NotificationListenerService() { serviceScope.launch { try { val id = NotificationDismissalUtil.getIdForSbn(sbn) ?: sbn.key - + // If this dismissal was initiated by our own dismiss request, skip echo val wasSuppressed = NotificationDismissalUtil.consumeSuppressed(id) if (wasSuppressed) { @@ -558,7 +560,7 @@ class MediaNotificationListener : NotificationListenerService() { ) ) WebSocketUtil.sendMessage(updateJson) - + Log.d(TAG, "Sent notification removal sync for $id") // Remove from caches since it's gone now diff --git a/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt b/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt index 50297b8e..4d7a376a 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt @@ -50,9 +50,16 @@ class NotificationActionReceiver : BroadcastReceiver() { if (!transferId.isNullOrEmpty()) { Log.d(TAG, "Cancelling transfer $transferId from notification") // Also try cancelling Quick Share transfer - val qsIntent = Intent(context, com.sameerasw.airsync.quickshare.QuickShareService::class.java).apply { - action = com.sameerasw.airsync.quickshare.QuickShareService.ACTION_CANCEL_TRANSFER - putExtra(com.sameerasw.airsync.quickshare.QuickShareService.EXTRA_TRANSFER_ID, transferId) + val qsIntent = Intent( + context, + com.sameerasw.airsync.quickshare.QuickShareService::class.java + ).apply { + action = + com.sameerasw.airsync.quickshare.QuickShareService.ACTION_CANCEL_TRANSFER + putExtra( + com.sameerasw.airsync.quickshare.QuickShareService.EXTRA_TRANSFER_ID, + transferId + ) } context.startService(qsIntent) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt b/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt index f24d088e..e428bba7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt @@ -30,10 +30,13 @@ class AdbMdnsDiscovery(context: Context) { override fun onServiceFound(serviceInfo: NsdServiceInfo) { Log.d(TAG, "Service found: ${serviceInfo.serviceName}") - + synchronized(discoveredServices) { if (discoveredServices.any { it.serviceName == serviceInfo.serviceName }) { - Log.d(TAG, "Service ${serviceInfo.serviceName} already discovered, skipping resolve") + Log.d( + TAG, + "Service ${serviceInfo.serviceName} already discovered, skipping resolve" + ) return } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt index c76da93e..23603636 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt @@ -6,9 +6,9 @@ import android.content.pm.PackageManager import android.media.AudioManager import android.os.Build import android.telecom.TelecomManager +import android.util.Log import android.view.KeyEvent import androidx.core.content.ContextCompat -import android.util.Log object CallControlUtil { private const val TAG = "CallControlUtil" @@ -26,7 +26,8 @@ object CallControlUtil { if (hasPermission) { try { - val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + val telecomManager = + context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager if (telecomManager != null) { Log.d(TAG, "Accepting ringing call via TelecomManager") telecomManager.acceptRingingCall() @@ -36,7 +37,10 @@ object CallControlUtil { Log.e(TAG, "Failed to accept ringing call via TelecomManager, falling back", e) } } else { - Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + Log.w( + TAG, + "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook" + ) } } @@ -57,7 +61,8 @@ object CallControlUtil { if (hasPermission) { try { - val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + val telecomManager = + context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager if (telecomManager != null) { Log.d(TAG, "Ending/declining call via TelecomManager") val success = telecomManager.endCall() @@ -70,7 +75,10 @@ object CallControlUtil { Log.e(TAG, "Failed to end call via TelecomManager, falling back", e) } } else { - Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + Log.w( + TAG, + "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook" + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 49d006cd..403a1c42 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -87,6 +87,7 @@ object DeviceInfoUtil { @Suppress("DEPRECATION") val wifiInfo = wifiManager.connectionInfo + @Suppress("DEPRECATION") val ipAddress = wifiInfo.ipAddress if (ipAddress != 0) { @@ -210,7 +211,7 @@ object DeviceInfoUtil { fun isBlurProblematicDevice(): Boolean { // Samsung devices on One UI 7 (Android 15) or below have a broken blur implementation // that causes a gray screen overlay. Disable it for them. (╯°□°)╯︵ ┻━┻ - return Build.MANUFACTURER.equals("samsung", ignoreCase = true) && + return Build.MANUFACTURER.equals("samsung", ignoreCase = true) && Build.VERSION.SDK_INT <= 35 // Android 15 } @@ -224,7 +225,8 @@ object DeviceInfoUtil { } fun isPowerSaveMode(context: Context): Boolean { - val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager + val powerManager = + context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager return powerManager?.isPowerSaveMode == true } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DevicePreviewResolver.kt b/app/src/main/java/com/sameerasw/airsync/utils/DevicePreviewResolver.kt index 81fb4aa8..fd65cf73 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DevicePreviewResolver.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DevicePreviewResolver.kt @@ -1,7 +1,6 @@ package com.sameerasw.airsync.utils import androidx.annotation.DrawableRes -import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice /** diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index 0ee5ca05..5c7193f2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -164,7 +164,8 @@ object JsonUtil { likeStatus: String ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" - val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" + val albumArtLiteJson = + if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index 65cacb01..e873edf9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -5,8 +5,8 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log -import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.data.ble.BleGattServer +import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.domain.model.MacBattery import com.sameerasw.airsync.domain.model.MacDeviceStatus import com.sameerasw.airsync.domain.model.MacMusicInfo @@ -159,7 +159,8 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { val ds = DataStoreManager(context) val isMediaControlsEnabled = ds.getMacMediaControlsEnabled().first() - val isConnected = WebSocketUtil.isConnected() || WebSocketUtil.isConnecting() || BleGattServer.isAnyAuthenticated() + val isConnected = + WebSocketUtil.isConnected() || WebSocketUtil.isConnecting() || BleGattServer.isAnyAuthenticated() val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt index 499f4828..69760501 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt @@ -45,7 +45,7 @@ object MacModelMapper { val nameStr = name.replace(" ", "").lowercase() val typeStr = deviceType?.replace(" ", "")?.lowercase() ?: "" val hay = "$nameStr$modelStr$typeStr".lowercase() - + return when { hay.contains("macbookair") -> R.drawable.rounded_laptop_mac_24 hay.contains("macbookpro") -> R.drawable.rounded_laptop_mac_24 @@ -63,25 +63,89 @@ object MacModelMapper { // 1) Explicit Model Matching based on user mapping return when { // MacBook Air Gen 3 - isMatch(model, listOf("Mac17,4", "Mac17,3", "Mac16,13", "Mac16,12", "Mac15,13", "Mac15,12", "Mac14,15", "Mac14,2")) -> R.drawable.macbook_air_gen3 + isMatch( + model, + listOf( + "Mac17,4", + "Mac17,3", + "Mac16,13", + "Mac16,12", + "Mac15,13", + "Mac15,12", + "Mac14,15", + "Mac14,2" + ) + ) -> R.drawable.macbook_air_gen3 // MacBook Air Gen 2 - isMatch(model, listOf("MacBookAir10,1", "MacBookAir9,1", "MacBookAir8,2", "MacBookAir8,1")) -> R.drawable.macbook_air_gen2 + isMatch( + model, + listOf("MacBookAir10,1", "MacBookAir9,1", "MacBookAir8,2", "MacBookAir8,1") + ) -> R.drawable.macbook_air_gen2 // MacBook Pro Gen 3 - isMatch(model, listOf("Mac17,7", "Mac17,9", "Mac17,6", "Mac17,8", "Mac17,2", "Mac16,1", "Mac16,6", "Mac16,8", "Mac16,7", "Mac16,5", "Mac15,3", "Mac15,6", "Mac15,8", "Mac15,10", "Mac15,7", "Mac15,9", "Mac15,11", "Mac14,5", "Mac14,9", "Mac14,6", "Mac14,10", "MacBookPro18,3", "MacBookPro18,4", "MacBookPro18,1", "MacBookPro18,2")) -> R.drawable.macbook_pro_gen3 + isMatch( + model, + listOf( + "Mac17,7", + "Mac17,9", + "Mac17,6", + "Mac17,8", + "Mac17,2", + "Mac16,1", + "Mac16,6", + "Mac16,8", + "Mac16,7", + "Mac16,5", + "Mac15,3", + "Mac15,6", + "Mac15,8", + "Mac15,10", + "Mac15,7", + "Mac15,9", + "Mac15,11", + "Mac14,5", + "Mac14,9", + "Mac14,6", + "Mac14,10", + "MacBookPro18,3", + "MacBookPro18,4", + "MacBookPro18,1", + "MacBookPro18,2" + ) + ) -> R.drawable.macbook_pro_gen3 // MacBook Pro Gen 2 - isMatch(model, listOf("Mac14,7", "MacBookPro17,1", "MacBookPro16,3", "MacBookPro16,2", "MacBookPro16,1", "MacBookPro16,4", "MacBookPro15,4", "MacBookPro15,1", "MacBookPro15,3", "MacBookPro15,2")) -> R.drawable.macbook_pro_gen2 + isMatch( + model, + listOf( + "Mac14,7", + "MacBookPro17,1", + "MacBookPro16,3", + "MacBookPro16,2", + "MacBookPro16,1", + "MacBookPro16,4", + "MacBookPro15,4", + "MacBookPro15,1", + "MacBookPro15,3", + "MacBookPro15,2" + ) + ) -> R.drawable.macbook_pro_gen2 // Mac mini Gen 3 isMatch(model, listOf("Mac16,11", "Mac16,10")) -> R.drawable.macmini_gen3 // iMac Gen 3 - isMatch(model, listOf("Mac16,3", "Mac16,2", "Mac15,5", "Mac15,4", "iMac21,1", "iMac21,2")) -> R.drawable.imac_gen3 + isMatch( + model, + listOf("Mac16,3", "Mac16,2", "Mac15,5", "Mac15,4", "iMac21,1", "iMac21,2") + ) -> R.drawable.imac_gen3 // iMac Gen 2 - isMatch(model, listOf("iMac20,1", "iMac20,2", "iMac19,1", "iMac19,2", "iMacPro1,1")) -> R.drawable.imac_gen2 + isMatch( + model, + listOf("iMac20,1", "iMac20,2", "iMac19,1", "iMac19,2", "iMacPro1,1") + ) -> R.drawable.imac_gen2 // MacBook Neo model.contains("Mac17,5", ignoreCase = true) -> R.drawable.macbook_neo diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index 49da76d9..e7d18211 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -209,7 +209,10 @@ object PermissionUtil { missing.add("Local Network Access") } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted( + context + ) + ) { missing.add("Answer Calls") } @@ -274,7 +277,10 @@ object PermissionUtil { optional.add("Local Network Access") } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted( + context + ) + ) { optional.add("Answer Calls") } @@ -310,14 +316,20 @@ object PermissionUtil { Manifest.permission.READ_PHONE_STATE ) == PackageManager.PERMISSION_GRANTED } - + /** * Check if Bluetooth permissions are granted (Connect and Advertise/Scan on Android 12+) */ fun isBluetoothPermissionsGranted(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT + ) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_ADVERTISE + ) == PackageManager.PERMISSION_GRANTED } else { // On older versions, manifest permissions are enough true diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt index 4a15335b..f8aa4b6a 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.launch * and user preferences for background features. */ object ServiceManager { - + /** * Determines if any background service should be running based on settings. */ @@ -22,7 +22,7 @@ object ServiceManager { val isConnected = WebSocketUtil.isConnected() val isAutoReconnectEnabled = dataStore.getAutoReconnectEnabled().first() val isDiscoveryEnabled = dataStore.getDeviceDiscoveryEnabled().first() - + // Service needs to run if: // 1. We are currently connected // 2. We need to auto-reconnect in the background diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt index c1235d7f..6fcc2400 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt @@ -31,10 +31,19 @@ object ShortcutUtil { ShortcutInfoCompat.Builder(context, SHORTCUT_ID_SCAN) .setShortLabel("Scan") .setLongLabel("Scan QR Code") - .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_qr_code_scanner_24)) - .setIntent(Intent(context, com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity::class.java).apply { - action = "com.sameerasw.airsync.SCAN_QR" - }) + .setIcon( + IconCompat.createWithResource( + context, + R.drawable.rounded_qr_code_scanner_24 + ) + ) + .setIntent( + Intent( + context, + com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity::class.java + ).apply { + action = "com.sameerasw.airsync.SCAN_QR" + }) .build() ) @@ -56,11 +65,20 @@ object ShortcutUtil { ShortcutInfoCompat.Builder(context, SHORTCUT_ID_REMOTE) .setShortLabel("Remote") .setLongLabel("Remote Control") - .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_compare_arrows_24)) - .setIntent(Intent(context, com.sameerasw.airsync.MainActivity::class.java).apply { - action = DASH_ACTION_REMOTE - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - }) + .setIcon( + IconCompat.createWithResource( + context, + R.drawable.rounded_compare_arrows_24 + ) + ) + .setIntent( + Intent( + context, + com.sameerasw.airsync.MainActivity::class.java + ).apply { + action = DASH_ACTION_REMOTE + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + }) .build() ) @@ -93,7 +111,12 @@ object ShortcutUtil { ShortcutInfoCompat.Builder(context, SHORTCUT_ID_DISCONNECT) .setShortLabel("Disconnect") .setLongLabel("Disconnect") - .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_mimo_disconnect_24)) + .setIcon( + IconCompat.createWithResource( + context, + R.drawable.rounded_mimo_disconnect_24 + ) + ) .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { action = DASH_ACTION_DISCONNECT }) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index cb414316..5c02e201 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -45,7 +45,9 @@ object SyncManager { // Heartbeat: Sync battery over BLE if authenticated and WS not connected to keep Mac connection alive if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && !WebSocketUtil.isConnected()) { val currentBattery = DeviceInfoUtil.getBatteryInfo(context) - com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus( + currentBattery + ) } // Check if sync is needed (either via WebSocket or BLE) @@ -201,8 +203,10 @@ object SyncManager { Log.d(TAG, "Discovered ADB ports: $adbPorts") val deviceId = DeviceInfoUtil.getDeviceId(context) - val symmetricKey = dataStoreManager.getLastConnectedDevice().first()?.symmetricKey ?: "" - val bleAuthToken = com.sameerasw.airsync.data.ble.BleTransportBridge.deriveAuthToken(symmetricKey) + val symmetricKey = + dataStoreManager.getLastConnectedDevice().first()?.symmetricKey ?: "" + val bleAuthToken = + com.sameerasw.airsync.data.ble.BleTransportBridge.deriveAuthToken(symmetricKey) val liteDeviceInfoJson = JsonUtil.createDeviceInfoJson( id = deviceId, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt index d520447f..8465d1ae 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -57,6 +57,7 @@ object UDPDiscoveryManager { @Volatile private var isRunning = false + @Volatile private var currentMode = DiscoveryMode.ACTIVE @@ -69,7 +70,8 @@ object UDPDiscoveryManager { private fun acquireMulticastLock(context: Context) { try { if (multicastLock == null) { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + val wm = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager multicastLock = wm.createMulticastLock("AirSync:DiscoveryLock") } if (multicastLock?.isHeld == false) { @@ -131,7 +133,7 @@ object UDPDiscoveryManager { Log.d(TAG, "Discovery disabled, skipping burst broadcast") return } - + Log.d(TAG, "Starting burst broadcast for ${durationMs}ms") burstJob?.cancel() burstJob = CoroutineScope(Dispatchers.IO).launch { @@ -240,7 +242,8 @@ object UDPDiscoveryManager { Log.e(TAG, "Error receiving packet: ${e.message}, recreating socket...") try { socket?.close() - } catch (_: Exception) {} + } catch (_: Exception) { + } socket = null delay(2000) } @@ -376,7 +379,7 @@ object UDPDiscoveryManager { private fun broadcastPresence(context: Context) { if (!isDiscoveryEnabled) return - + val allIps = getAllIpAddresses() if (allIps.isEmpty()) { return @@ -459,7 +462,7 @@ object UDPDiscoveryManager { private fun broadcastGoodbye(context: Context) { if (!isDiscoveryEnabled) return - + val allIps = getAllIpAddresses() if (allIps.isEmpty()) return diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt index 22c94ed3..5367f5ee 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt @@ -3,19 +3,35 @@ package com.sameerasw.airsync.utils import android.content.Context import android.os.Environment import android.util.Log -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.cio.* -import io.ktor.server.engine.* -import io.ktor.server.plugins.statuspages.* +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.encodeURLPathPart +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.ApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.request.path -import io.ktor.server.response.* -import io.ktor.server.routing.* +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondFile +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.head +import io.ktor.server.routing.method +import io.ktor.server.routing.route +import io.ktor.server.routing.routing import java.io.File import java.net.ServerSocket import java.net.URLDecoder import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale +import java.util.TimeZone class WebDavServer(private val context: Context) { private var engine: ApplicationEngine? = null @@ -116,7 +132,11 @@ class WebDavServer(private val context: Context) { val depth = call.request.headers["Depth"] ?: "1" val xml = buildPropfindXml(file, relativePath, depth) - call.respondText(xml, ContentType.Text.Xml.withParameter("charset", "utf-8"), HttpStatusCode.MultiStatus) + call.respondText( + xml, + ContentType.Text.Xml.withParameter("charset", "utf-8"), + HttpStatusCode.MultiStatus + ) } private fun buildPropfindXml(file: File, relativePath: String, depth: String): String { @@ -131,7 +151,8 @@ class WebDavServer(private val context: Context) { ?.filter { !it.name.startsWith(".") } ?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) ?.forEach { child -> - val childRelPath = if (relativePath.isEmpty()) child.name else "$relativePath/${child.name}" + val childRelPath = + if (relativePath.isEmpty()) child.name else "$relativePath/${child.name}" appendFileEntry(sb, child, childRelPath) } } @@ -170,7 +191,8 @@ class WebDavServer(private val context: Context) { } else { sb.append(" \n") sb.append(" ${file.length()}\n") - val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) + ?: "application/octet-stream" sb.append(" $contentType\n") } sb.append(" $lastModified\n") @@ -210,13 +232,17 @@ class WebDavServer(private val context: Context) { if (!file.isDirectory) { call.response.header(HttpHeaders.ContentLength, file.length().toString()) - val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) + ?: "application/octet-stream" call.response.header(HttpHeaders.ContentType, contentType) } val rfc1123Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { timeZone = TimeZone.getTimeZone("GMT") } - call.response.header(HttpHeaders.LastModified, rfc1123Format.format(Date(file.lastModified()))) + call.response.header( + HttpHeaders.LastModified, + rfc1123Format.format(Date(file.lastModified())) + ) call.respond(HttpStatusCode.OK) } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index 1c0cd75f..45e510f6 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -297,15 +297,15 @@ object WebSocketMessageHandler { // Send updated media state after successful control if (success) { - // For track skip actions (next/previous), add a delay to allow media player to update - CoroutineScope(Dispatchers.IO).launch { - val delayMs = when (action) { - "seekTo" -> 650L - "next", "previous" -> 1200L - else -> 400L // smaller delay for like/others - } - delay(delayMs) - SyncManager.onMediaStateChanged(context) + // For track skip actions (next/previous), add a delay to allow media player to update + CoroutineScope(Dispatchers.IO).launch { + val delayMs = when (action) { + "seekTo" -> 650L + "next", "previous" -> 1200L + else -> 400L // smaller delay for like/others + } + delay(delayMs) + SyncManager.onMediaStateChanged(context) } } } catch (e: Exception) { @@ -499,7 +499,10 @@ object WebSocketMessageHandler { // Update the Mac device status manager with all media info MacDeviceStatusManager.updateStatus( context = context, - name = data.optString("name", MacDeviceStatusManager.macDeviceStatus.value?.name ?: "Unknown"), + name = data.optString( + "name", + MacDeviceStatusManager.macDeviceStatus.value?.name ?: "Unknown" + ), batteryLevel = batteryLevel, isCharging = isCharging, isPaired = isPaired, @@ -895,7 +898,7 @@ object WebSocketMessageHandler { private fun handleRefreshAdbPorts(context: Context) { Log.d(TAG, "Request to refresh ADB ports received. Restarting discovery...") com.sameerasw.airsync.AdbDiscoveryHolder.restartDiscovery(context) - + CoroutineScope(Dispatchers.IO).launch { delay(2500) Log.d(TAG, "Sending refreshed device info with ADB ports after delay") @@ -932,8 +935,12 @@ object WebSocketMessageHandler { } Log.d(TAG, "Triggering Quick Share receiving mode via WebSocket") - val intent = Intent(context, com.sameerasw.airsync.quickshare.QuickShareService::class.java).apply { - action = com.sameerasw.airsync.quickshare.QuickShareService.ACTION_START_DISCOVERY + val intent = Intent( + context, + com.sameerasw.airsync.quickshare.QuickShareService::class.java + ).apply { + action = + com.sameerasw.airsync.quickshare.QuickShareService.ACTION_START_DISCOVERY } if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { context.startForegroundService(intent) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index c2b8a861..cdbd572a 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request @@ -290,7 +290,10 @@ object WebSocketUtil { Log.d(TAG, "RAW WebSocket message received: ${text}...") val decryptedMessage = currentSymmetricKey?.let { key -> val decrypted = CryptoUtil.decryptMessage(text, key) - if (decrypted == null) Log.e(TAG, "FAILED TO DECRYPT WebSocket message!") + if (decrypted == null) Log.e( + TAG, + "FAILED TO DECRYPT WebSocket message!" + ) decrypted } ?: text @@ -373,8 +376,13 @@ object WebSocketUtil { if (code != 1000) { if (com.sameerasw.airsync.AirSyncApp.isAppForeground()) { CoroutineScope(Dispatchers.Main).launch { - val msg = reason.ifEmpty { "Unknown Server Disconnect" } - android.widget.Toast.makeText(context, "Disconnected: $msg", android.widget.Toast.LENGTH_SHORT).show() + val msg = + reason.ifEmpty { "Unknown Server Disconnect" } + android.widget.Toast.makeText( + context, + "Disconnected: $msg", + android.widget.Toast.LENGTH_SHORT + ).show() } } } @@ -389,18 +397,22 @@ object WebSocketUtil { } catch (_: Exception) { } try { - com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia(context) - com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(context) + com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia( + context + ) + com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup( + context + ) } catch (_: Exception) { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - + // Only auto-reconnect if it wasn't a manual close (1000) if (code != 1000) { tryStartAutoReconnect(context) } - + try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -416,7 +428,8 @@ object WebSocketUtil { val totalToTry = ipList.size val failedCount = failedAttempts.incrementAndGet() val wasActive = webSocket == WebSocketUtil.webSocket - val isFinalManualAttempt = manualAttempt && !connectionStarted.get() && failedCount >= totalToTry + val isFinalManualAttempt = + manualAttempt && !connectionStarted.get() && failedCount >= totalToTry if (wasActive || isFinalManualAttempt) { if (manualAttempt || isSocketOpen.get()) { @@ -429,7 +442,11 @@ object WebSocketUtil { is java.io.EOFException, is java.net.SocketException -> "Lost connection to your mac" else -> t.message ?: "Unknown connection error" } - android.widget.Toast.makeText(context, "AirSync: $msg", android.widget.Toast.LENGTH_LONG).show() + android.widget.Toast.makeText( + context, + "AirSync: $msg", + android.widget.Toast.LENGTH_LONG + ).show() } } } @@ -445,17 +462,24 @@ object WebSocketUtil { } catch (_: Exception) { } try { - com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia(context) - com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(context) + com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia( + context + ) + com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup( + context + ) } catch (_: Exception) { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - + // Check manual disconnect flag before auto-reconnecting on failure CoroutineScope(Dispatchers.IO).launch { try { - val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val ds = + com.sameerasw.airsync.data.local.DataStoreManager.getInstance( + context + ) val manual = ds.getUserManuallyDisconnected().first() if (!manual) { tryStartAutoReconnect(context) @@ -539,7 +563,7 @@ object WebSocketUtil { Log.d(TAG, "WebSocket not connected, falling back to BLE: $message") return sendOverBLE(message) } - + Log.w(TAG, "Neither WebSocket nor BLE connected, cannot send message") return false } @@ -560,6 +584,7 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_ACTION, payload) return true } + "mediaControl" -> { val action = data.optString("action") // Protocol: type|action @@ -567,6 +592,7 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) return true } + "volumeControl" -> { val action = data.optString("action") // Protocol: type|action @@ -574,16 +600,19 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) return true } + "clipboard", "clipboardUpdate" -> { val content = data.optString("text", data.optString("content")) ble.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, content) return true } + "dismissNotification" -> { val id = data.optString("id") ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) return true } + "remoteControl" -> { val action = data.optString("action") // Filter out high-frequency cursor controls over BLE @@ -596,6 +625,7 @@ object WebSocketUtil { ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) return true } + "notification" -> { val pkg = data.optString("package") val appName = data.optString("app") @@ -604,11 +634,15 @@ object WebSocketUtil { BleTransportBridge.sendNotification(pkg, appName, title, body) return true } + "status" -> { val battery = data.optJSONObject("battery") if (battery != null) { val level = battery.optInt("level") - ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + ble.sendNotification( + BleConstants.CHAR_BATTERY_LEVEL, + byteArrayOf(level.toByte()) + ) } val music = data.optJSONObject("music") if (music != null) { @@ -655,14 +689,17 @@ object WebSocketUtil { } catch (_: Exception) { } } - + // Send manual disconnect signal over BLE before disconnecting BLE client try { val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() if (ble != null && ble.isAuthenticated) { Log.d(TAG, "Sending manual disconnect signal over BLE before disconnecting") - ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, "remote|manual_disconnect") - + ble.sendChunkedNotification( + BleConstants.CHAR_MAC_CONTROL, + "remote|manual_disconnect" + ) + CoroutineScope(Dispatchers.IO).launch { delay(300) ble.disconnectAllConnectedDevices() @@ -800,8 +837,12 @@ object WebSocketUtil { private fun acquireWifiLock(context: Context) { try { if (wifiLock == null) { - val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager - wifiLock = wm.createWifiLock(android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF, "AirSync:ReconnectLock") + val wm = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + wifiLock = wm.createWifiLock( + android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF, + "AirSync:ReconnectLock" + ) } if (wifiLock?.isHeld == false) { wifiLock?.acquire() @@ -845,9 +886,12 @@ object WebSocketUtil { while (autoReconnectActive.get() && !isConnected.get()) { val manual = ds.getUserManuallyDisconnected().first() val autoEnabled = ds.getAutoReconnectEnabled().first() - + if (manual || !autoEnabled) { - Log.d(TAG, "Auto-reconnect cancelled: manual=$manual, enabled=$autoEnabled") + Log.d( + TAG, + "Auto-reconnect cancelled: manual=$manual, enabled=$autoEnabled" + ) cancelAutoReconnect() break } @@ -856,13 +900,18 @@ object WebSocketUtil { val last = ds.getLastConnectedDevice().first() if (last != null) { val all = ds.getAllNetworkDeviceConnections().first() - val targetConnection = all.firstOrNull { it.deviceName == last.name } - + val targetConnection = + all.firstOrNull { it.deviceName == last.name } + if (targetConnection != null) { - val ips = targetConnection.networkConnections.values.joinToString(",") + val ips = + targetConnection.networkConnections.values.joinToString(",") val port = targetConnection.port.toIntOrNull() ?: 6996 - - Log.d(TAG, "Proactive retry to $ips:$port (backoff: ${backoffMs}ms)") + + Log.d( + TAG, + "Proactive retry to $ips:$port (backoff: ${backoffMs}ms)" + ) connect( context = context, ipAddress = ips, @@ -879,7 +928,7 @@ object WebSocketUtil { } } } - + delay(backoffMs) // Exponential backoff capped at 1 minute backoffMs = (backoffMs * 2).coerceAtMost(60_000L) @@ -891,7 +940,7 @@ object WebSocketUtil { if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect val last = ds.getLastConnectedDevice().first() ?: return@collect - + // Match by name within the discovery list val discoveryMatch = discoveredList.find { it.name == last.name } if (discoveryMatch != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt index 29042e56..7f0c76b1 100644 --- a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt +++ b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt @@ -106,16 +106,21 @@ class AirSyncWidgetProvider : AppWidgetProvider() { val widgetAlpha = runBlocking { ds.getWidgetTransparency().first() } // Apply background transparency - val baseBg = androidx.core.content.ContextCompat.getColor(context, R.color.widget_background) - val bgWithAlpha = androidx.core.graphics.ColorUtils.setAlphaComponent(baseBg, (widgetAlpha * 255).toInt().coerceIn(0, 255)) + val baseBg = + androidx.core.content.ContextCompat.getColor(context, R.color.widget_background) + val bgWithAlpha = androidx.core.graphics.ColorUtils.setAlphaComponent( + baseBg, + (widgetAlpha * 255).toInt().coerceIn(0, 255) + ) views.setInt(R.id.widget_container, "setBackgroundColor", bgWithAlpha) // Device image (large preview) and name val previewRes = DevicePreviewResolver.getPreviewRes(lastDevice) views.setImageViewResource(R.id.widget_device_image, previewRes) - + // Apply primary accent tint - val accentColor = androidx.core.content.ContextCompat.getColor(context, R.color.material_primary) + val accentColor = + androidx.core.content.ContextCompat.getColor(context, R.color.material_primary) views.setInt(R.id.widget_device_image, "setColorFilter", accentColor) // Dim the device image when not connected (including while connecting) diff --git a/app/src/main/res/drawable/brand_github.xml b/app/src/main/res/drawable/brand_github.xml index e26e25c0..492db5d6 100644 --- a/app/src/main/res/drawable/brand_github.xml +++ b/app/src/main/res/drawable/brand_github.xml @@ -28,9 +28,9 @@ SOFTWARE. android:viewportWidth="24" android:viewportHeight="24"> + android:strokeLineJoin="round" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/brand_telegram.xml b/app/src/main/res/drawable/brand_telegram.xml index 6c3318ef..4cba053c 100644 --- a/app/src/main/res/drawable/brand_telegram.xml +++ b/app/src/main/res/drawable/brand_telegram.xml @@ -28,9 +28,9 @@ SOFTWARE. android:viewportWidth="24" android:viewportHeight="24"> + android:strokeLineJoin="round" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/essentials_icon.xml b/app/src/main/res/drawable/essentials_icon.xml index cfe80112..fb484f05 100644 --- a/app/src/main/res/drawable/essentials_icon.xml +++ b/app/src/main/res/drawable/essentials_icon.xml @@ -3,47 +3,42 @@ android:height="24dp" android:viewportWidth="108" android:viewportHeight="108"> - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + android:pathData="M55.83,27.05C58.26,27.05 60.34,28.72 60.79,31.03L61,32.05C61.8,36.09 66.1,38.5 70.1,37.15L71.12,36.81C73.41,36.04 75.95,36.95 77.16,38.99L78.99,42.06C80.2,44.1 79.75,46.68 77.92,48.22L77.1,48.9C73.9,51.59 73.9,56.41 77.1,59.1L77.92,59.78C79.75,61.32 80.2,63.9 78.99,65.94L77.16,69.01C75.95,71.05 73.41,71.96 71.12,71.19L70.1,70.85C66.1,69.5 61.8,71.91 61,75.94L60.79,76.97C60.34,79.28 58.26,80.95 55.83,80.95H52.17C49.74,80.95 47.66,79.28 47.2,76.97L47,75.94C46.2,71.91 41.9,69.5 37.9,70.85L36.88,71.19C34.59,71.96 32.05,71.05 30.84,69.01L29.01,65.94C27.8,63.9 28.25,61.32 30.08,59.78L30.9,59.1C34.1,56.41 34.1,51.59 30.9,48.9L30.08,48.22C28.25,46.68 27.8,44.1 29.01,42.06L30.84,38.99C32.05,36.95 34.59,36.04 36.88,36.81L37.9,37.15C41.9,38.5 46.2,36.09 47,32.06L47.2,31.03C47.66,28.72 49.74,27.05 52.17,27.05H55.83ZM57.7,39.19C55.41,38.62 53,38.58 50.67,39.1C47.25,39.87 44.19,41.79 42.02,44.55C39.84,47.3 38.69,50.72 38.74,54.23C38.8,57.74 40.06,61.12 42.32,63.81C44.57,66.5 47.69,68.33 51.13,68.98C54.58,69.64 58.15,69.09 61.24,67.43C64.33,65.76 66.75,63.08 68.09,59.84C69.01,57.64 69.39,55.26 69.22,52.9C69.09,51.07 67.19,50.06 65.43,50.59C63.67,51.11 62.74,53 62.56,54.83C62.47,55.67 62.27,56.5 61.94,57.29C61.18,59.12 59.82,60.63 58.08,61.56C56.34,62.5 54.33,62.81 52.39,62.44C51.7,62.31 51.04,62.1 50.42,61.81C49.61,61.44 49.78,60.36 50.62,60.08L52.45,59.46C52.55,59.44 52.64,59.42 52.74,59.38L54.03,58.96L55.3,58.52C56.07,58.26 56.64,57.61 56.81,56.81L57.09,55.49L57.34,54.17C57.5,53.37 57.22,52.55 56.62,52.01L55.61,51.11L54.59,50.22C53.98,49.69 53.13,49.52 52.36,49.77L51.23,50.14C51.03,50.18 50.83,50.23 50.64,50.3L47.56,51.34C46.73,51.62 45.94,50.87 46.34,50.09C46.59,49.59 46.9,49.12 47.25,48.67C48.47,47.12 50.19,46.04 52.12,45.61C52.96,45.42 53.81,45.36 54.65,45.42C56.49,45.56 58.51,44.97 59.33,43.32C60.15,41.68 59.49,39.64 57.7,39.19Z" /> diff --git a/app/src/main/res/drawable/ic_clipboard_24.xml b/app/src/main/res/drawable/ic_clipboard_24.xml index 5234eeea..504748cd 100644 --- a/app/src/main/res/drawable/ic_clipboard_24.xml +++ b/app/src/main/res/drawable/ic_clipboard_24.xml @@ -1,30 +1,30 @@ - + android:viewportHeight="24"> + android:strokeWidth="2" + android:strokeColor="#000000" /> + android:strokeWidth="2" + android:strokeColor="#000000" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_desktop_24.xml b/app/src/main/res/drawable/ic_desktop_24.xml index 39e4ad73..03f4df1e 100644 --- a/app/src/main/res/drawable/ic_desktop_24.xml +++ b/app/src/main/res/drawable/ic_desktop_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_laptop_24.xml b/app/src/main/res/drawable/ic_laptop_24.xml index a81cbd52..6e653bc6 100644 --- a/app/src/main/res/drawable/ic_laptop_24.xml +++ b/app/src/main/res/drawable/ic_laptop_24.xml @@ -1,11 +1,11 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 25421b9d..bd5f69ed 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,5 +5,5 @@ android:viewportHeight="108"> + android:pathData="M0,0h108v108h-108z" /> diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml index b8ce5e2c..300a0aa4 100644 --- a/app/src/main/res/drawable/ic_launcher_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -7,7 +7,7 @@ android:fillColor="#000000" android:pathData="M48.629,28.8234C51.79,26.4364 56.1555,26.4365 59.3165,28.8234C59.4506,28.9247 59.5977,29.0453 59.8917,29.2853C60.0226,29.3923 60.0886,29.4453 60.1534,29.4962C61.641,30.6654 63.4649,31.3281 65.3575,31.3869C65.44,31.3894 65.5249,31.3906 65.6944,31.3927C66.0739,31.3974 66.2637,31.3999 66.4317,31.4084C70.3906,31.6077 73.7351,34.4083 74.6192,38.2648C74.6567,38.4284 74.6922,38.6152 74.7628,38.9875C74.7942,39.1533 74.8094,39.2369 74.8263,39.3175C75.2129,41.1673 76.184,42.8446 77.5958,44.1037C77.6574,44.1586 77.7213,44.2141 77.8497,44.3244C78.1375,44.5716 78.2811,44.6955 78.4044,44.8097C81.3086,47.5021 82.067,51.7922 80.2608,55.3136C80.1841,55.4631 80.0911,55.6287 79.9054,55.9591C79.8226,56.1065 79.7813,56.1805 79.7423,56.2531C78.847,57.9182 78.5097,59.8261 78.7804,61.6964C78.7922,61.7779 78.8058,61.8616 78.8331,62.0285C78.8943,62.4021 78.9245,62.5894 78.9454,62.756C79.4361,66.6817 77.2534,70.4556 73.6016,71.9943C73.4468,72.0595 73.269,72.126 72.9141,72.2599C72.7557,72.3197 72.6764,72.3504 72.5997,72.381C72.3035,72.4992 72.0152,72.6332 71.7354,72.7814C70.8652,70.386 69.2651,67.8806 69.2579,67.8693C69.2579,67.8693 64.674,60.8538 62.3302,57.2648C62.3493,57.2287 62.3699,57.1930 62.3917,57.1584C62.4615,57.0475 62.5505,56.9471 62.7266,56.7453L63.7208,55.6056C64.1565,55.1062 64.3743,54.8557 64.4806,54.5841C64.6342,54.1912 64.6343,53.7544 64.4806,53.3615C64.3742,53.0900 64.1563,52.8401 63.7208,52.3410L62.7266,51.2013C62.5504,50.9993 62.4615,50.8982 62.3917,50.7873C62.2911,50.6273 62.2188,50.4511 62.1768,50.2668C62.1478,50.1390 62.1383,50.0049 62.1202,49.7375L62.0177,48.2287C61.9728,47.5676 61.9498,47.2371 61.8331,46.9699C61.6641,46.5831 61.3554,46.2738 60.9688,46.1046C60.7016,45.9879 60.3709,45.9650 59.7100,45.9201L58.2013,45.8175C57.9343,45.7993 57.8005,45.7909 57.6729,45.7619C57.4886,45.7198 57.3125,45.6468 57.1524,45.5460C57.0416,45.4763 56.9402,45.3882 56.7384,45.2121L55.5987,44.2169C55.0999,43.7815 54.8505,43.5635 54.5792,43.4572C54.1864,43.3033 53.7495,43.3033 53.3565,43.4572C53.0851,43.5635 52.8352,43.7812 52.3360,44.2169L51.1964,45.2121C50.9948,45.3881 50.8940,45.4764 50.7833,45.5460C50.6230,45.6469 50.4464,45.7198 50.2618,45.7619C50.1342,45.7908 50.0005,45.7993 49.7335,45.8175L48.2247,45.9201C47.5637,45.9650 47.2330,45.9878 46.9659,46.1046C46.5793,46.2738 46.2707,46.5832 46.1016,46.9699C45.9850,47.2371 45.9619,47.5677 45.9171,48.2287L45.8145,49.7375C45.7964,50.0049 45.7880,50.1389 45.7589,50.2668C45.7170,50.4512 45.6437,50.6272 45.5431,50.7873C45.4734,50.8982 45.3852,50.9994 45.2091,51.2013L44.2149,52.3410C43.7795,52.8400 43.5615,53.0901 43.4552,53.3615C43.3015,53.7545 43.3015,54.1911 43.4552,54.5841C43.5615,54.8557 43.7793,55.1062 44.2149,55.6056L45.2091,56.7453C45.3850,56.9468 45.4734,57.0476 45.5431,57.1584C45.5860,57.2266 45.6228,57.2988 45.6554,57.3722C43.2851,60.9785 40.9131,64.5853 38.5431,68.1945C37.6014,69.6266 36.6415,71.1092 36.1163,72.7335C35.8658,72.6047 35.6092,72.4860 35.3458,72.3810C35.2691,72.3504 35.1898,72.3197 35.0313,72.2599C34.6765,72.1260 34.4987,72.0595 34.3438,71.9943C30.6920,70.4556 28.5094,66.6817 29.0001,62.7560C29.0209,62.5894 29.0511,62.4022 29.1124,62.0285C29.1397,61.8616 29.1543,61.7780 29.1661,61.6964C29.4367,59.8261 29.0995,57.9182 28.2042,56.2531C28.1652,56.1804 28.1230,56.1067 28.0401,55.9591C27.8545,55.6288 27.7623,55.4633 27.6856,55.3136C25.8793,51.7922 26.6370,47.5022 29.5411,44.8097C29.6643,44.6955 29.8081,44.5715 30.0958,44.3244C30.2243,44.2140 30.2891,44.1586 30.3507,44.1037C31.7624,42.8446 32.7325,41.1672 33.1192,39.3175C33.1360,39.2368 33.1522,39.1534 33.1837,38.9875C33.2543,38.6151 33.2898,38.4284 33.3272,38.2648C34.2114,34.4084 37.5551,31.6078 41.5138,31.4084C41.6817,31.3999 41.8724,31.3974 42.2520,31.3927C42.4209,31.3906 42.5057,31.3894 42.5880,31.3869C44.4805,31.3281 46.3045,30.6654 47.7921,29.4962C47.8569,29.4453 47.9228,29.3922 48.0538,29.2853C48.3476,29.0454 48.4948,28.9247 48.6290,28.8234Z" /> diff --git a/app/src/main/res/drawable/ic_mac_mini_24.xml b/app/src/main/res/drawable/ic_mac_mini_24.xml index c2184b70..7220b77c 100644 --- a/app/src/main/res/drawable/ic_mac_mini_24.xml +++ b/app/src/main/res/drawable/ic_mac_mini_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_mac_pro_24.xml b/app/src/main/res/drawable/ic_mac_pro_24.xml index beccb419..57e7e1df 100644 --- a/app/src/main/res/drawable/ic_mac_pro_24.xml +++ b/app/src/main/res/drawable/ic_mac_pro_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_mac_studio_24.xml b/app/src/main/res/drawable/ic_mac_studio_24.xml index c2184b70..7220b77c 100644 --- a/app/src/main/res/drawable/ic_mac_studio_24.xml +++ b/app/src/main/res/drawable/ic_mac_studio_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_stat_name.xml b/app/src/main/res/drawable/ic_stat_name.xml index 1e6ceb3a..67ac0336 100644 --- a/app/src/main/res/drawable/ic_stat_name.xml +++ b/app/src/main/res/drawable/ic_stat_name.xml @@ -2,11 +2,11 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/imac_gen2.xml b/app/src/main/res/drawable/imac_gen2.xml index f9835cb6..55f03d8f 100644 --- a/app/src/main/res/drawable/imac_gen2.xml +++ b/app/src/main/res/drawable/imac_gen2.xml @@ -3,23 +3,22 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - + android:fillColor="#D9D9D9" + android:pathData="M300,385C300.67,395.33 302.4,417.3 304,422.5C306,429 306,428.5 312.5,430C317.7,431.2 321.67,432.17 323,432.5C323.33,433.33 323.6,435.2 322,436C320.4,436.8 307.33,437 301,437H210.5C204.17,437 191.1,436.8 189.5,436C187.9,435.2 188.17,433.33 188.5,432.5C189.83,432.17 193.8,431.2 199,430C205.5,428.5 205.5,429 207.5,422.5C209.1,417.3 210.83,395.33 211.5,385H300Z" /> + + + + + + diff --git a/app/src/main/res/drawable/imac_gen3.xml b/app/src/main/res/drawable/imac_gen3.xml index 9dd61019..3ed05293 100644 --- a/app/src/main/res/drawable/imac_gen3.xml +++ b/app/src/main/res/drawable/imac_gen3.xml @@ -3,26 +3,25 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + + - - - - - + android:fillColor="#D9D9D9" + android:pathData="M32,366C32,370.42 35.58,374 40,374H471C475.42,374 479,370.42 479,366V329H32V366Z" /> + + + diff --git a/app/src/main/res/drawable/key_command.xml b/app/src/main/res/drawable/key_command.xml index 3a2bb0ed..af0ad8c7 100644 --- a/app/src/main/res/drawable/key_command.xml +++ b/app/src/main/res/drawable/key_command.xml @@ -3,11 +3,11 @@ android:height="800dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/key_control.xml b/app/src/main/res/drawable/key_control.xml index a308dd46..14df83c7 100644 --- a/app/src/main/res/drawable/key_control.xml +++ b/app/src/main/res/drawable/key_control.xml @@ -3,7 +3,7 @@ android:height="800dp" android:viewportWidth="56" android:viewportHeight="56"> - + diff --git a/app/src/main/res/drawable/key_option.xml b/app/src/main/res/drawable/key_option.xml index 79824f13..a0459224 100644 --- a/app/src/main/res/drawable/key_option.xml +++ b/app/src/main/res/drawable/key_option.xml @@ -3,11 +3,11 @@ android:height="800dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/key_shift.xml b/app/src/main/res/drawable/key_shift.xml index 0294fd9e..8ec55b48 100644 --- a/app/src/main/res/drawable/key_shift.xml +++ b/app/src/main/res/drawable/key_shift.xml @@ -3,7 +3,7 @@ android:height="800dp" android:viewportWidth="56" android:viewportHeight="56"> - + diff --git a/app/src/main/res/drawable/macbook_air_gen2.xml b/app/src/main/res/drawable/macbook_air_gen2.xml index 33642db0..bccb64b8 100644 --- a/app/src/main/res/drawable/macbook_air_gen2.xml +++ b/app/src/main/res/drawable/macbook_air_gen2.xml @@ -3,32 +3,31 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - - - + android:fillColor="#ffffff" + android:pathData="M64,366h384v13h-384z" /> + + + + + + + + + diff --git a/app/src/main/res/drawable/macbook_air_gen3.xml b/app/src/main/res/drawable/macbook_air_gen3.xml index ea0c2d00..dfead008 100644 --- a/app/src/main/res/drawable/macbook_air_gen3.xml +++ b/app/src/main/res/drawable/macbook_air_gen3.xml @@ -3,35 +3,34 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - - - - + android:fillColor="#ffffff" + android:pathData="M78,394.5C78,395.33 77.33,396 76.5,396H50.5C49.67,396 49,395.33 49,394.5C49,393.67 48.33,393 47.5,393H42.5C41.67,393 41,392.33 41,391.5C41,390.67 41.67,390 42.5,390H84.5C85.33,390 86,390.67 86,391.5C86,392.33 85.33,393 84.5,393H79.5C78.67,393 78,393.67 78,394.5Z" /> + + + + + + + + + + diff --git a/app/src/main/res/drawable/macbook_neo.xml b/app/src/main/res/drawable/macbook_neo.xml index c781de25..1525c0cc 100644 --- a/app/src/main/res/drawable/macbook_neo.xml +++ b/app/src/main/res/drawable/macbook_neo.xml @@ -3,32 +3,31 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - - - + android:fillColor="#ffffff" + android:pathData="M67,400.5C67,401.33 66.33,402 65.5,402H39.5C38.67,402 38,401.33 38,400.5C38,399.67 37.33,399 36.5,399H31.5C30.67,399 30,398.33 30,397.5C30,396.67 30.67,396 31.5,396H73.5C74.33,396 75,396.67 75,397.5C75,398.33 74.33,399 73.5,399H68.5C67.67,399 67,399.67 67,400.5Z" /> + + + + + + + + + diff --git a/app/src/main/res/drawable/macbook_pro_gen2.xml b/app/src/main/res/drawable/macbook_pro_gen2.xml index f295f391..758ef46e 100644 --- a/app/src/main/res/drawable/macbook_pro_gen2.xml +++ b/app/src/main/res/drawable/macbook_pro_gen2.xml @@ -3,28 +3,28 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - + + + + + + + diff --git a/app/src/main/res/drawable/macbook_pro_gen3.xml b/app/src/main/res/drawable/macbook_pro_gen3.xml index fde25723..6f845a93 100644 --- a/app/src/main/res/drawable/macbook_pro_gen3.xml +++ b/app/src/main/res/drawable/macbook_pro_gen3.xml @@ -3,31 +3,31 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/macmini_gen1.xml b/app/src/main/res/drawable/macmini_gen1.xml index 96709735..db0ad8cb 100644 --- a/app/src/main/res/drawable/macmini_gen1.xml +++ b/app/src/main/res/drawable/macmini_gen1.xml @@ -3,19 +3,19 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - + + + + + diff --git a/app/src/main/res/drawable/macmini_gen3.xml b/app/src/main/res/drawable/macmini_gen3.xml index 82ef14a0..acf466d8 100644 --- a/app/src/main/res/drawable/macmini_gen3.xml +++ b/app/src/main/res/drawable/macmini_gen3.xml @@ -3,16 +3,16 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - + + + + diff --git a/app/src/main/res/drawable/macpro_gen3.xml b/app/src/main/res/drawable/macpro_gen3.xml index da7b81c4..6370d36d 100644 --- a/app/src/main/res/drawable/macpro_gen3.xml +++ b/app/src/main/res/drawable/macpro_gen3.xml @@ -3,35 +3,34 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/macstudio_gen1.xml b/app/src/main/res/drawable/macstudio_gen1.xml index 067c2678..e67eb97a 100644 --- a/app/src/main/res/drawable/macstudio_gen1.xml +++ b/app/src/main/res/drawable/macstudio_gen1.xml @@ -3,13 +3,13 @@ android:height="512dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + diff --git a/app/src/main/res/drawable/outline_battery_full_24.xml b/app/src/main/res/drawable/outline_battery_full_24.xml index b4172971..4d799c6e 100644 --- a/app/src/main/res/drawable/outline_battery_full_24.xml +++ b/app/src/main/res/drawable/outline_battery_full_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_downloading_24.xml b/app/src/main/res/drawable/outline_downloading_24.xml index e7cfd195..cb1ff65a 100644 --- a/app/src/main/res/drawable/outline_downloading_24.xml +++ b/app/src/main/res/drawable/outline_downloading_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_expand_circle_down_24.xml b/app/src/main/res/drawable/outline_expand_circle_down_24.xml index e9ec09d9..b0884ecb 100644 --- a/app/src/main/res/drawable/outline_expand_circle_down_24.xml +++ b/app/src/main/res/drawable/outline_expand_circle_down_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_expand_circle_up_24.xml b/app/src/main/res/drawable/outline_expand_circle_up_24.xml index 76400fa2..08c04072 100644 --- a/app/src/main/res/drawable/outline_expand_circle_up_24.xml +++ b/app/src/main/res/drawable/outline_expand_circle_up_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_feedback_24.xml b/app/src/main/res/drawable/outline_feedback_24.xml index cc97afab..8d2c5147 100644 --- a/app/src/main/res/drawable/outline_feedback_24.xml +++ b/app/src/main/res/drawable/outline_feedback_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_info_24.xml b/app/src/main/res/drawable/outline_info_24.xml index 270273c7..eb7fe2d5 100644 --- a/app/src/main/res/drawable/outline_info_24.xml +++ b/app/src/main/res/drawable/outline_info_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_open_in_browser_24.xml b/app/src/main/res/drawable/outline_open_in_browser_24.xml index b2d652fa..5a2c3835 100644 --- a/app/src/main/res/drawable/outline_open_in_browser_24.xml +++ b/app/src/main/res/drawable/outline_open_in_browser_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/outline_pause_24.xml b/app/src/main/res/drawable/outline_pause_24.xml index f159da91..86d83067 100644 --- a/app/src/main/res/drawable/outline_pause_24.xml +++ b/app/src/main/res/drawable/outline_pause_24.xml @@ -3,8 +3,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/outline_play_arrow_24.xml b/app/src/main/res/drawable/outline_play_arrow_24.xml index 350cbcad..159270cc 100644 --- a/app/src/main/res/drawable/outline_play_arrow_24.xml +++ b/app/src/main/res/drawable/outline_play_arrow_24.xml @@ -3,8 +3,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/outline_skip_next_24.xml b/app/src/main/res/drawable/outline_skip_next_24.xml index 73ad19bb..179df7b1 100644 --- a/app/src/main/res/drawable/outline_skip_next_24.xml +++ b/app/src/main/res/drawable/outline_skip_next_24.xml @@ -3,8 +3,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/outline_skip_previous_24.xml b/app/src/main/res/drawable/outline_skip_previous_24.xml index 52e7af47..31ab087f 100644 --- a/app/src/main/res/drawable/outline_skip_previous_24.xml +++ b/app/src/main/res/drawable/outline_skip_previous_24.xml @@ -3,8 +3,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/quick_share.xml b/app/src/main/res/drawable/quick_share.xml index 7eef4496..2c5d4a57 100644 --- a/app/src/main/res/drawable/quick_share.xml +++ b/app/src/main/res/drawable/quick_share.xml @@ -18,10 +18,10 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - + + diff --git a/app/src/main/res/drawable/rounded_add_24.xml b/app/src/main/res/drawable/rounded_add_24.xml index 6ec9fed9..5b3f0f8a 100644 --- a/app/src/main/res/drawable/rounded_add_24.xml +++ b/app/src/main/res/drawable/rounded_add_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_android_24.xml b/app/src/main/res/drawable/rounded_android_24.xml index c412317c..1099c879 100644 --- a/app/src/main/res/drawable/rounded_android_24.xml +++ b/app/src/main/res/drawable/rounded_android_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml b/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml index 127e5660..babaa94c 100644 --- a/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml +++ b/app/src/main/res/drawable/rounded_android_wifi_3_bar_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_arrow_back_24.xml b/app/src/main/res/drawable/rounded_arrow_back_24.xml index f6012a30..ce6c5a8c 100644 --- a/app/src/main/res/drawable/rounded_arrow_back_24.xml +++ b/app/src/main/res/drawable/rounded_arrow_back_24.xml @@ -1,5 +1,13 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_arrow_forward_24.xml b/app/src/main/res/drawable/rounded_arrow_forward_24.xml index c72c75d8..2217c472 100644 --- a/app/src/main/res/drawable/rounded_arrow_forward_24.xml +++ b/app/src/main/res/drawable/rounded_arrow_forward_24.xml @@ -1,5 +1,13 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_asterisk_24.xml b/app/src/main/res/drawable/rounded_asterisk_24.xml index b7483053..cc62f92f 100644 --- a/app/src/main/res/drawable/rounded_asterisk_24.xml +++ b/app/src/main/res/drawable/rounded_asterisk_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_24.xml b/app/src/main/res/drawable/rounded_bluetooth_24.xml index 9600872a..c7593e7e 100644 --- a/app/src/main/res/drawable/rounded_bluetooth_24.xml +++ b/app/src/main/res/drawable/rounded_bluetooth_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml index 341352de..79cbb521 100644 --- a/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml +++ b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_blur_on_24.xml b/app/src/main/res/drawable/rounded_blur_on_24.xml index 760ffba9..e6b47eed 100644 --- a/app/src/main/res/drawable/rounded_blur_on_24.xml +++ b/app/src/main/res/drawable/rounded_blur_on_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_brightness_5_24.xml b/app/src/main/res/drawable/rounded_brightness_5_24.xml index eb982e63..6388e857 100644 --- a/app/src/main/res/drawable/rounded_brightness_5_24.xml +++ b/app/src/main/res/drawable/rounded_brightness_5_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_brightness_7_24.xml b/app/src/main/res/drawable/rounded_brightness_7_24.xml index 1343d0da..5c8e77d6 100644 --- a/app/src/main/res/drawable/rounded_brightness_7_24.xml +++ b/app/src/main/res/drawable/rounded_brightness_7_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_bug_report_24.xml b/app/src/main/res/drawable/rounded_bug_report_24.xml index 2899c219..03871abd 100644 --- a/app/src/main/res/drawable/rounded_bug_report_24.xml +++ b/app/src/main/res/drawable/rounded_bug_report_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_call_log_24.xml b/app/src/main/res/drawable/rounded_call_log_24.xml index eeeca9f2..1caf606d 100644 --- a/app/src/main/res/drawable/rounded_call_log_24.xml +++ b/app/src/main/res/drawable/rounded_call_log_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_check_24.xml b/app/src/main/res/drawable/rounded_check_24.xml index eb2564df..e8b1c651 100644 --- a/app/src/main/res/drawable/rounded_check_24.xml +++ b/app/src/main/res/drawable/rounded_check_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_compare_arrows_24.xml b/app/src/main/res/drawable/rounded_compare_arrows_24.xml index 39bd25ab..1b8fa55c 100644 --- a/app/src/main/res/drawable/rounded_compare_arrows_24.xml +++ b/app/src/main/res/drawable/rounded_compare_arrows_24.xml @@ -1,5 +1,13 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_contacts_24.xml b/app/src/main/res/drawable/rounded_contacts_24.xml index c98aa0d9..16b4881a 100644 --- a/app/src/main/res/drawable/rounded_contacts_24.xml +++ b/app/src/main/res/drawable/rounded_contacts_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_dark_mode_24.xml b/app/src/main/res/drawable/rounded_dark_mode_24.xml index 0788c72e..e981f426 100644 --- a/app/src/main/res/drawable/rounded_dark_mode_24.xml +++ b/app/src/main/res/drawable/rounded_dark_mode_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_devices_24.xml b/app/src/main/res/drawable/rounded_devices_24.xml index 6c6c4b6c..6014e542 100644 --- a/app/src/main/res/drawable/rounded_devices_24.xml +++ b/app/src/main/res/drawable/rounded_devices_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_devices_off_24.xml b/app/src/main/res/drawable/rounded_devices_off_24.xml index 11d4eec9..99ec61ff 100644 --- a/app/src/main/res/drawable/rounded_devices_off_24.xml +++ b/app/src/main/res/drawable/rounded_devices_off_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_drag_click_24.xml b/app/src/main/res/drawable/rounded_drag_click_24.xml index 0719f269..6a1ce507 100644 --- a/app/src/main/res/drawable/rounded_drag_click_24.xml +++ b/app/src/main/res/drawable/rounded_drag_click_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_draw_24.xml b/app/src/main/res/drawable/rounded_draw_24.xml index ac3976f2..eae58adc 100644 --- a/app/src/main/res/drawable/rounded_draw_24.xml +++ b/app/src/main/res/drawable/rounded_draw_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_extension_24.xml b/app/src/main/res/drawable/rounded_extension_24.xml index 73d85e26..7aa0c93a 100644 --- a/app/src/main/res/drawable/rounded_extension_24.xml +++ b/app/src/main/res/drawable/rounded_extension_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_folder_managed_24.xml b/app/src/main/res/drawable/rounded_folder_managed_24.xml index 39d3faf3..81002a36 100644 --- a/app/src/main/res/drawable/rounded_folder_managed_24.xml +++ b/app/src/main/res/drawable/rounded_folder_managed_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_gamepad_circle_up_24.xml b/app/src/main/res/drawable/rounded_gamepad_circle_up_24.xml index 324cda20..9d374d42 100644 --- a/app/src/main/res/drawable/rounded_gamepad_circle_up_24.xml +++ b/app/src/main/res/drawable/rounded_gamepad_circle_up_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_heart_smile_24.xml b/app/src/main/res/drawable/rounded_heart_smile_24.xml index 436ff14c..f17a0c5f 100644 --- a/app/src/main/res/drawable/rounded_heart_smile_24.xml +++ b/app/src/main/res/drawable/rounded_heart_smile_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_history_24.xml b/app/src/main/res/drawable/rounded_history_24.xml index a273bff9..99e8f6c6 100644 --- a/app/src/main/res/drawable/rounded_history_24.xml +++ b/app/src/main/res/drawable/rounded_history_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_info_24.xml b/app/src/main/res/drawable/rounded_info_24.xml index ccb4a26b..f5d2b8c9 100644 --- a/app/src/main/res/drawable/rounded_info_24.xml +++ b/app/src/main/res/drawable/rounded_info_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_invert_colors_24.xml b/app/src/main/res/drawable/rounded_invert_colors_24.xml index 4a137ecc..d5656bc3 100644 --- a/app/src/main/res/drawable/rounded_invert_colors_24.xml +++ b/app/src/main/res/drawable/rounded_invert_colors_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_keyboard_arrow_right_24.xml b/app/src/main/res/drawable/rounded_keyboard_arrow_right_24.xml index f00e768d..ae1ffa87 100644 --- a/app/src/main/res/drawable/rounded_keyboard_arrow_right_24.xml +++ b/app/src/main/res/drawable/rounded_keyboard_arrow_right_24.xml @@ -1,5 +1,13 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_laptop_mac_24.xml b/app/src/main/res/drawable/rounded_laptop_mac_24.xml index b8c1fd91..404807f3 100644 --- a/app/src/main/res/drawable/rounded_laptop_mac_24.xml +++ b/app/src/main/res/drawable/rounded_laptop_mac_24.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/rounded_link_off_24.xml b/app/src/main/res/drawable/rounded_link_off_24.xml index 97f9a7a9..26a43212 100644 --- a/app/src/main/res/drawable/rounded_link_off_24.xml +++ b/app/src/main/res/drawable/rounded_link_off_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_local_laundry_service_24.xml b/app/src/main/res/drawable/rounded_local_laundry_service_24.xml index fc833691..5b2df14f 100644 --- a/app/src/main/res/drawable/rounded_local_laundry_service_24.xml +++ b/app/src/main/res/drawable/rounded_local_laundry_service_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_lock_24.xml b/app/src/main/res/drawable/rounded_lock_24.xml index 8eeaf62b..e87bf793 100644 --- a/app/src/main/res/drawable/rounded_lock_24.xml +++ b/app/src/main/res/drawable/rounded_lock_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml b/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml index c007dca1..029cbf79 100644 --- a/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml +++ b/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_mobile_check_24.xml b/app/src/main/res/drawable/rounded_mobile_check_24.xml index a1b8d88b..39d1b48d 100644 --- a/app/src/main/res/drawable/rounded_mobile_check_24.xml +++ b/app/src/main/res/drawable/rounded_mobile_check_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml b/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml index dcbcaf79..e9922793 100644 --- a/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml +++ b/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml b/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml index b4bcaf1f..5e8fb308 100644 --- a/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml +++ b/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_music_cast_24.xml b/app/src/main/res/drawable/rounded_music_cast_24.xml index 4fd8dcc2..4b0b0c76 100644 --- a/app/src/main/res/drawable/rounded_music_cast_24.xml +++ b/app/src/main/res/drawable/rounded_music_cast_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_network_node_24.xml b/app/src/main/res/drawable/rounded_network_node_24.xml index c52cd7a9..60ee39b3 100644 --- a/app/src/main/res/drawable/rounded_network_node_24.xml +++ b/app/src/main/res/drawable/rounded_network_node_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_notification_settings_24.xml b/app/src/main/res/drawable/rounded_notification_settings_24.xml index 39176180..3e7aa19f 100644 --- a/app/src/main/res/drawable/rounded_notification_settings_24.xml +++ b/app/src/main/res/drawable/rounded_notification_settings_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_notifications_active_24.xml b/app/src/main/res/drawable/rounded_notifications_active_24.xml index 90a1acf3..7f622658 100644 --- a/app/src/main/res/drawable/rounded_notifications_active_24.xml +++ b/app/src/main/res/drawable/rounded_notifications_active_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_qr_code_scanner_24.xml b/app/src/main/res/drawable/rounded_qr_code_scanner_24.xml index 0a452aa7..f72dc730 100644 --- a/app/src/main/res/drawable/rounded_qr_code_scanner_24.xml +++ b/app/src/main/res/drawable/rounded_qr_code_scanner_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_remove_24.xml b/app/src/main/res/drawable/rounded_remove_24.xml index d0b84241..8d9550cc 100644 --- a/app/src/main/res/drawable/rounded_remove_24.xml +++ b/app/src/main/res/drawable/rounded_remove_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml b/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml index b26baa75..dbd6abd4 100644 --- a/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml +++ b/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_security_24.xml b/app/src/main/res/drawable/rounded_security_24.xml index 4c5c7e69..26faeb3d 100644 --- a/app/src/main/res/drawable/rounded_security_24.xml +++ b/app/src/main/res/drawable/rounded_security_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_settings_phone_24.xml b/app/src/main/res/drawable/rounded_settings_phone_24.xml index 33b114da..b8ddfc12 100644 --- a/app/src/main/res/drawable/rounded_settings_phone_24.xml +++ b/app/src/main/res/drawable/rounded_settings_phone_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_shield_toggle_24.xml b/app/src/main/res/drawable/rounded_shield_toggle_24.xml index 74e500a3..084742d7 100644 --- a/app/src/main/res/drawable/rounded_shield_toggle_24.xml +++ b/app/src/main/res/drawable/rounded_shield_toggle_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_smart_display_24.xml b/app/src/main/res/drawable/rounded_smart_display_24.xml index e653df58..f83d7a84 100644 --- a/app/src/main/res/drawable/rounded_smart_display_24.xml +++ b/app/src/main/res/drawable/rounded_smart_display_24.xml @@ -13,8 +13,15 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_star_shine_24.xml b/app/src/main/res/drawable/rounded_star_shine_24.xml index 785acaaf..8fba5bf7 100644 --- a/app/src/main/res/drawable/rounded_star_shine_24.xml +++ b/app/src/main/res/drawable/rounded_star_shine_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_sync_desktop_24.xml b/app/src/main/res/drawable/rounded_sync_desktop_24.xml index d7ac461d..0cdd949e 100644 --- a/app/src/main/res/drawable/rounded_sync_desktop_24.xml +++ b/app/src/main/res/drawable/rounded_sync_desktop_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_task_alt_24.xml b/app/src/main/res/drawable/rounded_task_alt_24.xml index fa0ffe7d..cd9cfaf1 100644 --- a/app/src/main/res/drawable/rounded_task_alt_24.xml +++ b/app/src/main/res/drawable/rounded_task_alt_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_troubleshoot_24.xml b/app/src/main/res/drawable/rounded_troubleshoot_24.xml index da84f242..aba9b5b1 100644 --- a/app/src/main/res/drawable/rounded_troubleshoot_24.xml +++ b/app/src/main/res/drawable/rounded_troubleshoot_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_web_24.xml b/app/src/main/res/drawable/rounded_web_24.xml index 22faee8b..fa59d368 100644 --- a/app/src/main/res/drawable/rounded_web_24.xml +++ b/app/src/main/res/drawable/rounded_web_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/rounded_web_traffic_24.xml b/app/src/main/res/drawable/rounded_web_traffic_24.xml index bd74b6ac..b399bb6c 100644 --- a/app/src/main/res/drawable/rounded_web_traffic_24.xml +++ b/app/src/main/res/drawable/rounded_web_traffic_24.xml @@ -1,5 +1,12 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/widget_preview.xml b/app/src/main/res/drawable/widget_preview.xml index bcccf8ec..60f3bd5f 100644 --- a/app/src/main/res/drawable/widget_preview.xml +++ b/app/src/main/res/drawable/widget_preview.xml @@ -11,10 +11,10 @@ + android:pathData="M8,0L172,0A8,8 0 0,1 180,8L180,102A8,8 0 0,1 172,110L8,110A8,8 0 0,1 0,102L0,8A8,8 0 0,1 8,0z" + android:strokeWidth="1" + android:strokeColor="#E0E0E0" /> + android:strokeColor="#79747E" /> diff --git a/app/src/main/res/drawable/widget_tap_hint_bg.xml b/app/src/main/res/drawable/widget_tap_hint_bg.xml index a43cec2c..9495e894 100644 --- a/app/src/main/res/drawable/widget_tap_hint_bg.xml +++ b/app/src/main/res/drawable/widget_tap_hint_bg.xml @@ -7,8 +7,8 @@ android:width="1dp" android:color="@color/widget_border" /> + android:top="4dp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/widget_airsync.xml b/app/src/main/res/layout/widget_airsync.xml index 62365419..28e9dc82 100644 --- a/app/src/main/res/layout/widget_airsync.xml +++ b/app/src/main/res/layout/widget_airsync.xml @@ -3,11 +3,11 @@ android:id="@+id/widget_container" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" - android:padding="12dp" android:background="@drawable/widget_background" android:clickable="true" - android:gravity="center_horizontal"> + android:gravity="center_horizontal" + android:orientation="vertical" + android:padding="12dp"> + android:src="@drawable/ic_laptop_24" /> @@ -43,13 +43,13 @@ android:id="@+id/widget_battery_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:orientation="horizontal" android:layout_gravity="center" - android:gravity="center_vertical" android:background="@drawable/widget_tap_hint_bg" + android:gravity="center_vertical" + android:orientation="horizontal" android:paddingStart="8dp" - android:paddingEnd="8dp" android:paddingTop="4dp" + android:paddingEnd="8dp" android:paddingBottom="4dp" android:visibility="gone"> @@ -59,8 +59,8 @@ android:layout_height="16dp" android:adjustViewBounds="true" android:scaleType="centerInside" - android:tint="@color/widget_primary_text" - android:src="@drawable/outline_battery_full_24" /> + android:src="@drawable/outline_battery_full_24" + android:tint="@color/widget_primary_text" /> + android:textStyle="bold" /> @@ -94,19 +94,19 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="AirSync" - android:textStyle="bold" + android:textColor="@color/widget_primary_text" android:textSize="16sp" - android:textColor="@color/widget_primary_text" /> + android:textStyle="bold" /> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 04091bd1..b070c763 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 04091bd1..b070c763 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - - + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/splash.xml b/app/src/main/res/values-night/splash.xml index 5846b743..d9a662bd 100644 --- a/app/src/main/res/values-night/splash.xml +++ b/app/src/main/res/values-night/splash.xml @@ -1,5 +1,5 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 5551dbb1..95c3329d 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -13,7 +13,7 @@ - + diff --git a/app/src/main/res/xml/widget_airsync_info.xml b/app/src/main/res/xml/widget_airsync_info.xml index 81ca3c85..b420db68 100644 --- a/app/src/main/res/xml/widget_airsync_info.xml +++ b/app/src/main/res/xml/widget_airsync_info.xml @@ -5,9 +5,9 @@ android:initialLayout="@layout/widget_airsync" android:minWidth="180dp" android:minHeight="110dp" - android:resizeMode="horizontal|vertical" android:previewImage="@drawable/widget_preview" android:previewLayout="@layout/widget_airsync" + android:resizeMode="horizontal|vertical" android:targetCellWidth="3" android:targetCellHeight="2" android:updatePeriodMillis="1800000" diff --git a/app/src/test/java/com/sameerasw/airsync/ExampleUnitTest.kt b/app/src/test/java/com/sameerasw/airsync/ExampleUnitTest.kt index 50caa84a..5c4d8236 100644 --- a/app/src/test/java/com/sameerasw/airsync/ExampleUnitTest.kt +++ b/app/src/test/java/com/sameerasw/airsync/ExampleUnitTest.kt @@ -1,9 +1,8 @@ package com.sameerasw.airsync +import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.Assert.* - /** * Example local unit test, which will execute on the development machine (host). * From 5db9d7ce6cafd79bdee91505abb9b9b62ca2a57d Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 28 May 2026 10:19:50 +0530 Subject: [PATCH 2/6] fix: implement manual pong responses and remove internal ping interval to improve WebSocket connection stability --- .../com/sameerasw/airsync/utils/WebSocketMessageHandler.kt | 4 ++++ .../main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index 45e510f6..07b644a8 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -425,6 +425,10 @@ object WebSocketMessageHandler { private fun handlePing(context: Context) { try { + // Reply immediately with lightweight pong message to keep session active + val pongJson = "{\"type\":\"pong\",\"data\":{}}" + WebSocketUtil.sendMessage(pongJson) + // Respond to ping with current device status to keep connection alive // We must force sync here because the server expects a response to every ping SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index cdbd572a..d83f3902 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -76,10 +76,6 @@ object WebSocketUtil { .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // Keep connection alive - .pingInterval( - 20, - TimeUnit.SECONDS - ) // Send ping every 20 seconds .build() } From 4d33b68c555f831258a04c451a2f0cfac423f400 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 28 May 2026 12:59:37 +0530 Subject: [PATCH 3/6] refactor: update surface color tokens for ConnectionStatusCard and RemoteFunctionsCard components --- .../ui/components/cards/ConnectionStatusCard.kt | 2 +- .../ui/components/cards/RemoteFunctionsCard.kt | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index e4555386..261d438e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -180,7 +180,7 @@ fun ConnectionStatusCard( onDisconnect() }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), modifier = Modifier.height(48.dp) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt index 6e183a30..316b2ccb 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt @@ -40,7 +40,7 @@ fun RemoteFunctionsCard( modifier = modifier .fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest) + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceBright) ) { Row( modifier = Modifier @@ -56,7 +56,7 @@ fun RemoteFunctionsCard( onRemoteAction("lock_screen") }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), contentPadding = PaddingValues(horizontal = 8.dp), @@ -84,7 +84,7 @@ fun RemoteFunctionsCard( onRemoteAction("screensaver") }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), contentPadding = PaddingValues(horizontal = 8.dp), @@ -112,7 +112,7 @@ fun RemoteFunctionsCard( onRemoteAction("brightness_down") }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), contentPadding = PaddingValues(0.dp), @@ -134,7 +134,7 @@ fun RemoteFunctionsCard( onRemoteAction("brightness_up") }, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, contentColor = MaterialTheme.colorScheme.onSecondaryContainer ), contentPadding = PaddingValues(0.dp), From b249afd2d1a18f74c7bacaaed13eb370962407bd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 28 May 2026 17:47:41 +0530 Subject: [PATCH 4/6] fix: connection status notification --- .../airsync/service/AirSyncService.kt | 65 +++++++++++++++++-- .../sameerasw/airsync/utils/WebSocketUtil.kt | 4 ++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 3af04463..d7850963 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -50,12 +50,26 @@ class AirSyncService : Service() { // Network state tracking private var networkCallback: ConnectivityManager.NetworkCallback? = null + private val connectionStatusListener: (Boolean) -> Unit = { _ -> + scope.launch { + updateNotification() + } + } + override fun onCreate() { super.onCreate() Log.d(TAG, "AirSyncService created") createNotificationChannel() MacDeviceStatusManager.startMonitoring(this) registerNetworkCallback() + WebSocketUtil.registerConnectionStatusListener(connectionStatusListener) + + // Monitor connection status, auto-reconnect, and battery status to update notification live + scope.launch { + MacDeviceStatusManager.macDeviceStatus.collect { + updateNotification() + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -240,6 +254,15 @@ class AirSyncService : Service() { } } + private fun updateNotification() { + try { + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, buildNotification()) + } catch (e: Exception) { + Log.e(TAG, "Error updating foreground notification", e) + } + } + private fun buildNotification(): Notification { val intent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) @@ -258,18 +281,49 @@ class AirSyncService : Service() { .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - if (isScanning && connectedDeviceName == null) { + val isConnected = WebSocketUtil.isConnected() + val isAuto = WebSocketUtil.isAutoReconnecting() + val isConnecting = WebSocketUtil.isConnecting() + + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + val lastDevice = runBlocking { dataStoreManager.getLastConnectedDevice().first() } + val macStatus = MacDeviceStatusManager.macDeviceStatus.value + + if (isConnected && lastDevice != null) { + val name = lastDevice.name builder.setContentTitle(getString(R.string.app_name)) - builder.setContentText(getString(R.string.no_device_connected)) - } else { - val name = connectedDeviceName ?: "Mac" + + val batteryText = macStatus?.let { status -> + val level = status.battery.level + if (level >= 0) { + val pct = level.coerceIn(0, 100) + if (status.battery.isCharging) " ($pct% Charging)" else " ($pct%)" + } else "" + } ?: "" + + builder.setContentText(getString(R.string.connected_to_device, name) + batteryText) + builder.addAction( + R.drawable.rounded_link_off_24, + getString(R.string.disconnect), + disconnectPendingIntent + ) + } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { builder.setContentTitle(getString(R.string.app_name)) - builder.setContentText(getString(R.string.connected_to_device, name)) + builder.setContentText("Connected to ${lastDevice.name} via Bluetooth") builder.addAction( R.drawable.rounded_link_off_24, getString(R.string.disconnect), disconnectPendingIntent ) + } else if (isAuto) { + builder.setContentTitle("Reconnecting...") + builder.setContentText(if (isConnecting) "Trying to connect to Mac..." else "Waiting to retry connection...") + } else if (isConnecting) { + builder.setContentTitle("Connecting...") + builder.setContentText("Connecting to last device...") + } else { + builder.setContentTitle(getString(R.string.app_name)) + builder.setContentText(getString(R.string.no_device_connected)) } return builder.build() @@ -279,6 +333,7 @@ class AirSyncService : Service() { override fun onDestroy() { Log.d(TAG, "AirSyncService destroyed") + WebSocketUtil.unregisterConnectionStatusListener(connectionStatusListener) networkCallback?.let { try { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index d83f3902..0881916c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -41,6 +41,7 @@ object WebSocketUtil { private fun updateConnectedStatus(status: Boolean) { isConnected.set(status) _connectionStateFlow.value = status + notifyConnectionStatusListeners(status) } // Transport state: true after OkHttp onOpen, false after closing/failure/disconnect @@ -147,6 +148,7 @@ object WebSocketUtil { isConnecting.set(true) handshakeCompleted.set(false) + notifyConnectionStatusListeners(false) // Reset manual disconnect flag on manual attempt if (manualAttempt) { @@ -820,6 +822,7 @@ object WebSocketUtil { autoReconnectJob = null autoReconnectAttempts = 0 autoReconnectStartTime = 0L + notifyConnectionStatusListeners(false) } fun isAutoReconnecting(): Boolean = autoReconnectActive.get() @@ -868,6 +871,7 @@ object WebSocketUtil { if (autoReconnectActive.get()) return // already running autoReconnectActive.set(true) autoReconnectStartTime = System.currentTimeMillis() + notifyConnectionStatusListeners(false) Log.d(TAG, "Starting Smart Auto-Reconnect strategy") autoReconnectJob?.cancel() From 77474a69ede282e50fde735c19a204e82a377251 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 07:10:04 +0530 Subject: [PATCH 5/6] fix: #116 Continue browsing reverted license check --- .../sameerasw/airsync/utils/ClipboardSyncManager.kt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt index 28acc8ae..126634f7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt @@ -172,15 +172,7 @@ object ClipboardSyncManager { } catch (_: Exception) { true } - // Only for Plus and while connected - val isConnected = WebSocketUtil.isConnected() - val last = try { - dataStoreManager.getLastConnectedDevice().first() - } catch (_: Exception) { - null - } - val isPlus = last?.isPlus == true - if (continueEnabled && isConnected && isPlus && isLinkOnly(text)) { + if (continueEnabled && isConnected && isLinkOnly(text)) { NotificationUtil.showContinueBrowsingLink(context, text.trim(), keepPrevious) } } From d6d7736ed446b250e241832345178e9df8fb5298 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 14:27:53 +0530 Subject: [PATCH 6/6] feat: Progress notification support --- .../service/MediaNotificationListener.kt | 50 +++++++++++++++---- .../airsync/utils/ClipboardSyncManager.kt | 2 +- .../com/sameerasw/airsync/utils/JsonUtil.kt | 12 ++++- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index ed8c31c5..32d88ea9 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -643,12 +643,13 @@ class MediaNotificationListener : NotificationListenerService() { return@launch } - // Generate unique notification ID - val notificationId = NotificationDismissalUtil.generateNotificationId( - sbn.packageName, - title, - sbn.postTime - ) + // Retrieve existing notification ID or generate a new one + val notificationId = NotificationDismissalUtil.getIdBySystemKey(sbn.key) + ?: NotificationDismissalUtil.generateNotificationId( + sbn.packageName, + title, + sbn.postTime + ) // Store notification for potential dismissal or actions NotificationDismissalUtil.storeNotification(notificationId, sbn) @@ -670,10 +671,37 @@ class MediaNotificationListener : NotificationListenerService() { Log.w(TAG, "Failed to extract actions: ${e.message}") } + // Check for progress bar extras + var progress: Int? = null + var progressMax: Int? = null + var progressIndeterminate: Boolean? = null + val ongoing = (notification.flags and Notification.FLAG_ONGOING_EVENT) != 0 + + try { + val hasProgress = extras.containsKey(Notification.EXTRA_PROGRESS) || + extras.containsKey(Notification.EXTRA_PROGRESS_MAX) + if (hasProgress) { + val maxVal = extras.getInt(Notification.EXTRA_PROGRESS_MAX, 0) + val progressVal = extras.getInt(Notification.EXTRA_PROGRESS, 0) + val indeterminateVal = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) + + if (maxVal > 0 || indeterminateVal) { + progress = progressVal + progressMax = maxVal + progressIndeterminate = indeterminateVal + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse notification progress: ${e.message}") + } + // Get notification priority (alerting vs silent) - val priority = getNotificationPriority(sbn) + var priority = getNotificationPriority(sbn) + if (progressMax != null || progressIndeterminate == true) { + priority = "silent" + } - // Create notification JSON with actions + // Create notification JSON with actions and progress details val notificationJson = JsonUtil.toSingleLine( JsonUtil.createNotificationJson( id = notificationId, @@ -682,7 +710,11 @@ class MediaNotificationListener : NotificationListenerService() { app = appName, packageName = sbn.packageName, priority = priority, - actions = actions + actions = actions, + progress = progress, + progressMax = progressMax, + progressIndeterminate = progressIndeterminate, + ongoing = ongoing ) ) diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt index 126634f7..1962c4b1 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ClipboardSyncManager.kt @@ -172,7 +172,7 @@ object ClipboardSyncManager { } catch (_: Exception) { true } - if (continueEnabled && isConnected && isLinkOnly(text)) { + if (continueEnabled && WebSocketUtil.isConnected() && isLinkOnly(text)) { NotificationUtil.showContinueBrowsingLink(context, text.trim(), keepPrevious) } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index 5c7193f2..0709674e 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -104,7 +104,11 @@ object JsonUtil { app: String, packageName: String, priority: String = "alerting", - actions: List> + actions: List>, + progress: Int? = null, + progressMax: Int? = null, + progressIndeterminate: Boolean? = null, + ongoing: Boolean? = null ): String { val actionsJson = if (actions.isNotEmpty()) { val items = actions.joinToString(",") { (name, type) -> @@ -114,11 +118,15 @@ object JsonUtil { } else { "" } + val progressJson = if (progress != null) ",\"progress\":$progress" else "" + val progressMaxJson = if (progressMax != null) ",\"progressMax\":$progressMax" else "" + val progressIndeterminateJson = if (progressIndeterminate != null) ",\"progressIndeterminate\":$progressIndeterminate" else "" + val ongoingJson = if (ongoing != null) ",\"ongoing\":$ongoing" else "" return """{"type":"notification","data":{"id":"$id","title":"${escape(title)}","body":"${ escape( body ) - }","app":"${escape(app)}","package":"${escape(packageName)}","priority":"$priority"$actionsJson}}""" + }","app":"${escape(app)}","package":"${escape(packageName)}","priority":"$priority"$actionsJson$progressJson$progressMaxJson$progressIndeterminateJson$ongoingJson}}""" } /**