From 989e0c8ad9ab82603a14738069c7fab3300e1f98 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 25 Mar 2026 12:30:38 +0530 Subject: [PATCH 01/12] fix: foreground crash fix with persistent notification --- .../java/com/sameerasw/airsync/quickshare/QuickShareService.kt | 2 ++ 1 file changed, 2 insertions(+) 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 bbeb37a..4d21697 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt @@ -63,6 +63,8 @@ class QuickShareService : Service() { 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 From da450bb23630eea69fba1de079980b7f15216395 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 25 Mar 2026 12:57:44 +0530 Subject: [PATCH 02/12] fix: reliable adb port reporting on connection --- .../com/sameerasw/airsync/MainActivity.kt | 11 ++++++++ .../airsync/utils/AdbMdnsDiscovery.kt | 26 ++++++++++++++----- .../sameerasw/airsync/utils/SyncManager.kt | 2 +- .../airsync/utils/WebSocketMessageHandler.kt | 10 +++++-- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 8da1a07..bd534f9 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -75,6 +75,17 @@ object AdbDiscoveryHolder { } } + fun restartDiscovery(context: android.content.Context) { + Log.d("AdbDiscoveryHolder", "Restarting ADB discovery") + discovery?.stopDiscovery() + discovery = null + initialize(context) + } + + fun isDiscoveryActive(): Boolean { + return discovery != null + } + fun getDiscoveredServices(): List { return discovery?.getDiscoveredServices() ?: emptyList() } 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 b89fb3c..f24d088 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/AdbMdnsDiscovery.kt @@ -30,6 +30,14 @@ 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") + return + } + } + // Resolve the service to get host and port information nsdManager.resolveService(serviceInfo, object : NsdManager.ResolveListener { override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { @@ -49,13 +57,17 @@ class AdbMdnsDiscovery(context: Context) { ) // Store the discovered service - discoveredServices.add( - AdbServiceInfo( - serviceName = resolvedInfo.serviceName, - hostAddress = hostAddress, - port = port - ) - ) + synchronized(discoveredServices) { + if (discoveredServices.none { it.serviceName == resolvedInfo.serviceName }) { + discoveredServices.add( + AdbServiceInfo( + serviceName = resolvedInfo.serviceName, + hostAddress = hostAddress, + port = port + ) + ) + } + } } }) } 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 852e5ce..3da4251 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -214,7 +214,7 @@ object SyncManager { null } val discoveredServices = try { - AdbMdnsDiscovery(context).getDiscoveredServices() + com.sameerasw.airsync.AdbDiscoveryHolder.getDiscoveredServices() } catch (e: Exception) { emptyList() } 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 12dca51..441c482 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -853,8 +853,14 @@ object WebSocketMessageHandler { } private fun handleRefreshAdbPorts(context: Context) { - Log.d(TAG, "Request to refresh ADB ports received") - SyncManager.sendDeviceInfoNow(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") + SyncManager.sendDeviceInfoNow(context) + } } private fun isVersionOutdated(current: String, min: String): Boolean { From 756f9addaf777029569d22b3cd8f5429e4f1fc6a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 28 Mar 2026 23:18:43 +0530 Subject: [PATCH 03/12] fix: oversized toolbar --- .../ui/components/AirSyncFloatingToolbar.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 c10fdac..5ebc9dc 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 @@ -30,6 +30,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.airsync.presentation.ui.models.AirSyncTab @@ -46,6 +48,15 @@ fun AirSyncFloatingToolbar( ) { // Persistent visibility var expanded by remember { mutableStateOf(true) } + 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 shouldHideLabel = isLargeFont || (isCompactScreen && tabs.size > 3) HorizontalFloatingToolbar( // modifier = modifier @@ -76,7 +87,7 @@ fun AirSyncFloatingToolbar( // Animate label width for active tab val labelWidth by animateDpAsState( - targetValue = if (isSelected) 80.dp else 0.dp, + targetValue = if (isSelected && !shouldHideLabel) 80.dp else 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow @@ -132,7 +143,7 @@ fun AirSyncFloatingToolbar( modifier = Modifier.size(24.dp) ) } - if (isSelected) { + if (isSelected && !shouldHideLabel) { Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(id = tab.title), From c78f9a795e35eb902e0eace2b9cd87a23a619d36 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 28 Mar 2026 23:43:34 +0530 Subject: [PATCH 04/12] feat: gradient overlay in ProgressiveBlurModifier --- .../ui/modifiers/ProgressiveBlurModifier.kt | 47 ++++++++++++++----- .../ui/screens/AirSyncMainScreen.kt | 23 ++++----- 2 files changed, 44 insertions(+), 26 deletions(-) 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 d843cdb..24f2027 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 @@ -1,9 +1,10 @@ package com.sameerasw.airsync.presentation.ui.modifiers -import android.graphics.RenderEffect -import android.graphics.RuntimeShader -import android.os.Build -import androidx.compose.ui.Modifier +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asComposeRenderEffect import androidx.compose.ui.graphics.graphicsLayer @@ -72,12 +73,13 @@ private val PROGRESSIVE_BLUR_SKSL = """ fun Modifier.progressiveBlur( blurRadius: Float, height: Float, - direction: BlurDirection = BlurDirection.TOP -): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - this.then( + direction: BlurDirection = BlurDirection.TOP, + 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 { - if (blurRadius <= 0f) return@graphicsLayer - val shader = RuntimeShader(PROGRESSIVE_BLUR_SKSL) shader.setFloatUniform("blurRadius", blurRadius) shader.setFloatUniform("height", height) @@ -87,7 +89,28 @@ fun Modifier.progressiveBlur( renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content") .asComposeRenderEffect() } - ) -} else { - this + } else Modifier + + val gradientModifier = if (showGradientOverlay) { + Modifier.drawWithContent { + drawContent() + val (brush, gradientHeight) = when (direction) { + BlurDirection.TOP -> { + Brush.verticalGradient( + colors = listOf(overlayColor, Color.Transparent), + endY = height + ) to height + } + BlurDirection.BOTTOM -> { + Brush.verticalGradient( + colors = listOf(Color.Transparent, overlayColor), + startY = size.height - height + ) to height + } + } + drawRect(brush = brush) + } + } else Modifier + + 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 06d5409..b6937df 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 @@ -719,20 +719,15 @@ fun AirSyncMainScreen( HorizontalPager( modifier = modifier .fillMaxSize() - .then( - if (uiState.isBlurEnabled) { - Modifier - .progressiveBlur( - blurRadius = 40f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - .progressiveBlur( - blurRadius = 40f, - height = bottomBlurHeightPx, - direction = BlurDirection.BOTTOM - ) - } else Modifier + .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 -> From 33dd66e276b2fbefc38201a511a92f0cd027eac7 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 29 Mar 2026 02:28:42 +0530 Subject: [PATCH 05/12] feat: add RenderEffect imports to ProgressiveBlurModifier and enable minification for debug builds --- .../presentation/ui/modifiers/ProgressiveBlurModifier.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 24f2027..860c2a2 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 @@ -1,6 +1,10 @@ package com.sameerasw.airsync.presentation.ui.modifiers +import android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.os.Build import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Brush From 658d04198b5c81b800a7f2ba0a12aac1eaec4b10 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 10 Apr 2026 23:28:09 +0530 Subject: [PATCH 06/12] feat: implement multicast and wifi locks for discovery and reconnection, and optimize WebSocket auto-reconnect strategy --- .../airsync/utils/UDPDiscoveryManager.kt | 36 +++- .../sameerasw/airsync/utils/WebSocketUtil.kt | 168 ++++++++++++++---- 2 files changed, 163 insertions(+), 41 deletions(-) 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 237e812..73a240b 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -64,6 +64,34 @@ object UDPDiscoveryManager { @Volatile private var isDiscoveryEnabled = true + private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null + + private fun acquireMulticastLock(context: Context) { + try { + if (multicastLock == null) { + val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager + multicastLock = wm.createMulticastLock("AirSync:DiscoveryLock") + } + if (multicastLock?.isHeld == false) { + multicastLock?.acquire() + Log.d(TAG, "MulticastLock acquired") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to acquire MulticastLock: ${e.message}") + } + } + + private fun releaseMulticastLock() { + try { + if (multicastLock?.isHeld == true) { + multicastLock?.release() + Log.d(TAG, "MulticastLock released") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to release MulticastLock: ${e.message}") + } + } + fun start(context: Context, discoveryEnabled: Boolean = true) { isDiscoveryEnabled = discoveryEnabled if (isRunning) { @@ -77,6 +105,7 @@ object UDPDiscoveryManager { "Starting UDP Discovery Manager (Discovery: $isDiscoveryEnabled, Mode: $currentMode)" ) + acquireMulticastLock(context) startListening(context) updateBroadcastingState(context) startPruning() @@ -108,15 +137,17 @@ object UDPDiscoveryManager { if (!isDiscoveryEnabled) { Log.d(TAG, "Discovery broadcasting disabled completely") _discoveredDevices.value = emptyList() + releaseMulticastLock() return } if (currentMode == DiscoveryMode.ACTIVE) { + acquireMulticastLock(context) startBroadcasting(context) } else { Log.d(TAG, "Switched to PASSIVE discovery (listening only)") - // In passive mode, we don't clear the list immediately, allowing some persistence - // but we rely on pruning to clean up stale devices + // In passive mode, we still need MulticastLock to hear others + acquireMulticastLock(context) } } @@ -132,6 +163,7 @@ object UDPDiscoveryManager { pruningJob?.cancel() burstJob?.cancel() + releaseMulticastLock() try { socket?.close() } catch (e: Exception) { 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 1e59e68..c48d9ed 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -59,13 +59,13 @@ object WebSocketUtil { private fun createClient(): OkHttpClient { return OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) + .connectTimeout(15, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS) // Keep connection alive .pingInterval( - 5, + 20, TimeUnit.SECONDS - ) // Send ping every 5 seconds for fast disconnect detection + ) // Send ping every 20 seconds .build() } @@ -137,6 +137,16 @@ object WebSocketUtil { isConnecting.set(true) handshakeCompleted.set(false) + + // Reset manual disconnect flag on manual attempt + if (manualAttempt) { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + ds.setUserManuallyDisconnected(false) + } catch (_: Exception) { + } + } + // Update widgets to show "Connecting…" immediately try { AirSyncWidgetProvider.updateAllWidgets(context) @@ -373,7 +383,12 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - tryStartAutoReconnect(context) + + // Only auto-reconnect if it wasn't a manual close (1000) + if (code != 1000) { + tryStartAutoReconnect(context) + } + try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -426,7 +441,20 @@ object WebSocketUtil { } onConnectionStatusChanged?.invoke(false) notifyConnectionStatusListeners(false) - tryStartAutoReconnect(context) + + // 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 manual = ds.getUserManuallyDisconnected().first() + if (!manual) { + tryStartAutoReconnect(context) + } + } catch (_: Exception) { + tryStartAutoReconnect(context) + } + } + try { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { @@ -513,14 +541,22 @@ object WebSocketUtil { handshakeTimeoutJob?.cancel() currentIpAddress = null - // Stop periodic sync when disconnecting - SyncManager.stopPeriodicSync() + // Set manual disconnect flag + val ctx = context ?: appContext + ctx?.let { c -> + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) + ds.setUserManuallyDisconnected(true) + } catch (_: Exception) { + } + } + } webSocket?.close(1000, "Manual disconnection") webSocket = null // Transition back to scanning on disconnect - val ctx = context ?: appContext ctx?.let { c -> try { com.sameerasw.airsync.service.AirSyncService.startScanning(c) @@ -639,42 +675,107 @@ object WebSocketUtil { cancelAutoReconnect() } + private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null + + 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") + } + if (wifiLock?.isHeld == false) { + wifiLock?.acquire() + Log.d(TAG, "WifiLock acquired for reconnection") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to acquire WifiLock: ${e.message}") + } + } + + private fun releaseWifiLock() { + try { + if (wifiLock?.isHeld == true) { + wifiLock?.release() + Log.d(TAG, "WifiLock released") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to release WifiLock: ${e.message}") + } + } + /** * Internal logic to attempt auto-reconnection to the last known device. - * Uses discovery-triggered strategy. + * Uses a dual strategy: proactive exponential backoff AND discovery-triggered. */ private fun tryStartAutoReconnect(context: Context) { if (autoReconnectActive.get()) return // already running autoReconnectActive.set(true) autoReconnectStartTime = System.currentTimeMillis() + Log.d(TAG, "Starting Smart Auto-Reconnect strategy") autoReconnectJob?.cancel() autoReconnectJob = CoroutineScope(Dispatchers.IO).launch { try { val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + acquireWifiLock(context) + + // 1. Retry Loop (Try last known IPs immediately and periodically) + launch { + var backoffMs = 2000L + 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") + cancelAutoReconnect() + break + } + + if (!isConnecting.get()) { + val last = ds.getLastConnectedDevice().first() + if (last != null) { + val all = ds.getAllNetworkDeviceConnections().first() + val targetConnection = all.firstOrNull { it.deviceName == last.name } + + if (targetConnection != null) { + val ips = targetConnection.networkConnections.values.joinToString(",") + val port = targetConnection.port.toIntOrNull() ?: 6996 + + Log.d(TAG, "Proactive retry to $ips:$port (backoff: ${backoffMs}ms)") + connect( + context = context, + ipAddress = ips, + port = port, + symmetricKey = targetConnection.symmetricKey, + manualAttempt = false, + onConnectionStatus = { connected -> + if (connected) { + releaseWifiLock() + cancelAutoReconnect() + } + } + ) + } + } + } + + delay(backoffMs) + // Exponential backoff capped at 1 minute + backoffMs = (backoffMs * 2).coerceAtMost(60_000L) + } + } - // Monitor discovered devices + // 2. Discovery Monitoring (Listen for presence packets in case IP changed) UDPDiscoveryManager.discoveredDevices.collect { discoveredList -> if (!autoReconnectActive.get() || isConnected.get() || isConnecting.get()) return@collect - val manual = ds.getUserManuallyDisconnected().first() - val autoEnabled = ds.getAutoReconnectEnabled().first() - if (manual || !autoEnabled) { - cancelAutoReconnect() - return@collect - } - val last = ds.getLastConnectedDevice().first() ?: return@collect - DeviceInfoUtil.getWifiIpAddress(context) - ?: return@collect - + // Match by name within the discovery list val discoveryMatch = discoveredList.find { it.name == last.name } if (discoveryMatch != null) { - Log.d( - TAG, - "Discovery found target device: ${discoveryMatch.name} with IPs: ${discoveryMatch.ips}" - ) + Log.d(TAG, "Discovery-triggered reconnect for: ${discoveryMatch.name}") val all = ds.getAllNetworkDeviceConnections().first() val targetConnection = all.firstOrNull { it.deviceName == last.name } @@ -683,10 +784,6 @@ object WebSocketUtil { val ips = discoveryMatch.ips.joinToString(",") val port = targetConnection.port.toIntOrNull() ?: 6996 - Log.d( - TAG, - "Smart Auto-reconnect attempting parallel connections to $ips:$port" - ) connect( context = context, ipAddress = ips, @@ -695,16 +792,8 @@ object WebSocketUtil { manualAttempt = false, onConnectionStatus = { connected -> if (connected) { - CoroutineScope(Dispatchers.IO).launch { - try { - ds.updateNetworkDeviceLastConnected( - targetConnection.deviceName, - System.currentTimeMillis() - ) - } catch (_: Exception) { - } - cancelAutoReconnect() - } + releaseWifiLock() + cancelAutoReconnect() } } ) @@ -712,7 +801,8 @@ object WebSocketUtil { } } } catch (e: Exception) { - Log.e(TAG, "Error in discovery auto-reconnect: ${e.message}") + Log.e(TAG, "Error in smart auto-reconnect: ${e.message}") + releaseWifiLock() } } } From 4774268b343c5bf1d10c2d668492c32323e1e304 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 00:36:25 +0530 Subject: [PATCH 07/12] feat: add dynamic share shortcut support to allow sending text directly to desktop from other apps --- app/src/main/AndroidManifest.xml | 15 ++++-- .../ui/activities/ClipboardActionActivity.kt | 48 ++++++++++++++----- .../airsync/service/AirSyncService.kt | 2 + .../sameerasw/airsync/utils/ShortcutUtil.kt | 38 +++++++++++++++ app/src/main/res/xml/shortcuts.xml | 7 +++ 5 files changed, 96 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt create mode 100644 app/src/main/res/xml/shortcuts.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 70324cd..973848e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,10 +83,13 @@ - + + @@ -94,10 +97,16 @@ + android:taskAffinity=""> + + + + + + Unit) { +fun ClipboardActionScreen( + hasWindowFocus: Boolean, + isShareAction: Boolean, + onFinished: () -> Unit +) { val context = androidx.compose.ui.platform.LocalContext.current val dataStoreManager = remember { DataStoreManager.getInstance(context) } val connectedDevice by dataStoreManager.getLastConnectedDevice().collectAsState(initial = null) @@ -115,26 +120,37 @@ fun ClipboardActionScreen(hasWindowFocus: Boolean, onFinished: () -> Unit) { ClipboardActionScreenContent( uiState = uiState, connectedDevice = connectedDevice, + isShareAction = isShareAction, onFinished = onFinished ) LaunchedEffect(hasWindowFocus) { if (hasWindowFocus && !hasAttemptedSync) { hasAttemptedSync = true - // Small delay to ensure system considers us "interacted" if needed, - // though focus should be enough. delay(100) try { - val clipboardText = ClipboardUtil.getClipboardText(context) + // If this is a share action, extract text from intent + val activity = context as? android.app.Activity + val intent = activity?.intent + val sharedText = if (isShareAction) { + intent?.getStringExtra(android.content.Intent.EXTRA_TEXT) + } else { + null + } - if (!clipboardText.isNullOrEmpty()) { - ClipboardSyncManager.syncTextToDesktop(clipboardText) + // Fallback to clipboard only if not a share action or shared text is empty + val textToSync = sharedText ?: ClipboardUtil.getClipboardText(context) + + if (!textToSync.isNullOrEmpty()) { + ClipboardSyncManager.syncTextToDesktop(textToSync) uiState = ClipboardUiState.Success - delay(1200) // Show success for 1.2s + delay(1200) onFinished() } else { - uiState = ClipboardUiState.Error("Clipboard empty") + uiState = ClipboardUiState.Error( + if (isShareAction) "Shared text empty" else "Clipboard empty" + ) delay(1500) onFinished() } @@ -152,6 +168,7 @@ fun ClipboardActionScreen(hasWindowFocus: Boolean, onFinished: () -> Unit) { fun ClipboardActionScreenContent( uiState: ClipboardUiState, connectedDevice: ConnectedDevice?, + isShareAction: Boolean, onFinished: () -> Unit ) { // Transparent background that dismisses on click @@ -252,9 +269,15 @@ fun ClipboardActionScreenContent( // Status Text Text( text = when (state) { - is ClipboardUiState.Loading -> stringResource(R.string.sending) - is ClipboardUiState.Success -> stringResource(R.string.clipboard_sent) - is ClipboardUiState.Error -> stringResource(R.string.failed_to_send_clipboard) + is ClipboardUiState.Loading -> { + if (isShareAction) "Sending shared text…" else stringResource(R.string.sending) + } + is ClipboardUiState.Success -> { + if (isShareAction) "Shared text sent" else stringResource(R.string.clipboard_sent) + } + is ClipboardUiState.Error -> { + if (isShareAction) "Failed to send shared text" else stringResource(R.string.failed_to_send_clipboard) + } }, style = MaterialTheme.typography.titleSmall, color = if (state is ClipboardUiState.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface @@ -279,6 +302,7 @@ private fun ClipboardActionScreenPreviewLoading() { ClipboardActionScreenContent( uiState = ClipboardUiState.Loading, connectedDevice = null, + isShareAction = false, onFinished = {}) } } @@ -290,6 +314,7 @@ private fun ClipboardActionScreenPreviewSuccess() { ClipboardActionScreenContent( uiState = ClipboardUiState.Success, connectedDevice = null, + isShareAction = false, onFinished = {}) } } @@ -301,6 +326,7 @@ private fun ClipboardActionScreenPreviewError() { ClipboardActionScreenContent( uiState = ClipboardUiState.Error("Failed to sync"), connectedDevice = null, + isShareAction = false, onFinished = {}) } } 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 002e36a..6ba5375 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -58,6 +58,7 @@ class AirSyncService : Service() { ACTION_START_SYNC -> { connectedDeviceName = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "Mac" startSync() + com.sameerasw.airsync.utils.ShortcutUtil.updateShareShortcut(this, connectedDeviceName) } ACTION_STOP_SYNC -> stopSync() @@ -139,6 +140,7 @@ class AirSyncService : Service() { Log.d(TAG, "Stopping AirSync foreground service") UDPDiscoveryManager.stop(this) WakeupService.stopService(this) + com.sameerasw.airsync.utils.ShortcutUtil.removeShareShortcut(this) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt new file mode 100644 index 0000000..2a8f62b --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt @@ -0,0 +1,38 @@ +package com.sameerasw.airsync.utils + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.sameerasw.airsync.R +import com.sameerasw.airsync.presentation.ui.activities.ClipboardActionActivity + +object ShortcutUtil { + private const val SHORTCUT_ID_SEND_TO_MAC = "shortcut_send_to_mac" + + fun updateShareShortcut(context: Context, macName: String?) { + val macDisplayName = macName ?: context.getString(R.string.your_mac) + + val shortcut = ShortcutInfoCompat.Builder(context, SHORTCUT_ID_SEND_TO_MAC) + .setShortLabel(macDisplayName) + .setLongLabel(context.getString(R.string.tab_clipboard) + " - " + macDisplayName) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_laptop_24)) + .setIntent( + Intent(context, ClipboardActionActivity::class.java).apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "") // Placeholder + addCategory(Intent.CATEGORY_DEFAULT) + } + ) + .setCategories(setOf("com.sameerasw.airsync.categories.SHARE_TARGET")) + .setLongLived(true) + .build() + + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + + fun removeShareShortcut(context: Context) { + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(SHORTCUT_ID_SEND_TO_MAC)) + } +} diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 0000000..e5ef4db --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,7 @@ + + + + + + + From 53e5220f472e4ca04958944b78d9d4a4ada6e58a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 00:49:36 +0530 Subject: [PATCH 08/12] refactor: redesign ClipboardActionActivity UI to a compact pill-shaped status bar at the bottom --- .../ui/activities/ClipboardActionActivity.kt | 166 ++++++++---------- 1 file changed, 73 insertions(+), 93 deletions(-) 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 6470e7c..37a7fd7 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 @@ -17,6 +17,7 @@ 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 @@ -26,9 +27,10 @@ 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.Icons 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 @@ -175,113 +177,91 @@ fun ClipboardActionScreenContent( Box( modifier = Modifier .fillMaxSize() - .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.2f)) - - .navigationBarsPadding() .clickable(onClick = onFinished), - contentAlignment = Alignment.Center + contentAlignment = Alignment.BottomCenter ) { Surface( - modifier = Modifier.padding(24.dp), - shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 6.dp, - shadowElevation = 8.dp + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = 64.dp) + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(percent = 50), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 8.dp, + shadowElevation = 12.dp ) { - Box( + Row( modifier = Modifier - .padding(24.dp), - contentAlignment = Alignment.Center + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp) ) { + // Device Icon + Icon( + painter = painterResource(id = R.drawable.ic_laptop_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + // Device Name + Text( + text = connectedDevice?.name ?: stringResource(R.string.your_mac), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + // Divider or Space if needed, but spacing is enough + + // Status Icon / Loading Indicator AnimatedContent( targetState = uiState, transitionSpec = { fadeIn().togetherWith(fadeOut()) }, - label = "ClipboardStateAnimation" + label = "StatusAnimation" ) { state -> - Column( - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center ) { - // Device preview with overlay - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.padding(bottom = 16.dp) - ) { - val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Device Preview", - modifier = Modifier.fillMaxWidth(0.9f), - contentScale = ContentScale.Fit - ) - - Box( - modifier = Modifier - .size(56.dp) - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - shape = CircleShape - ) - ) { - // Overlay icon/indicator - when (state) { - is ClipboardUiState.Loading -> { - LoadingIndicator( - modifier = Modifier.size(56.dp), - color = MaterialTheme.colorScheme.primary - ) - } - - is ClipboardUiState.Success -> { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = "Success", - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - is ClipboardUiState.Error -> { - Icon( - imageVector = Icons.Rounded.Error, - contentDescription = "Error", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(56.dp) - ) - } - } + when (state) { + is ClipboardUiState.Loading -> { + LoadingIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary + ) } - } - Text( - text = connectedDevice?.name ?: stringResource(R.string.your_mac), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier - .background( - MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(32.dp) + is ClipboardUiState.Success -> { + Icon( + imageVector = androidx.compose.material.icons.Icons.Rounded.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary ) - .padding(horizontal = 16.dp, vertical = 4.dp), - ) - - Spacer(modifier = Modifier.height(8.dp)) + } - // Status Text - Text( - text = when (state) { - is ClipboardUiState.Loading -> { - if (isShareAction) "Sending shared text…" else stringResource(R.string.sending) - } - is ClipboardUiState.Success -> { - if (isShareAction) "Shared text sent" else stringResource(R.string.clipboard_sent) - } - is ClipboardUiState.Error -> { - if (isShareAction) "Failed to send shared text" else stringResource(R.string.failed_to_send_clipboard) - } - }, - style = MaterialTheme.typography.titleSmall, - color = if (state is ClipboardUiState.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) + is ClipboardUiState.Error -> { + Icon( + imageVector = androidx.compose.material.icons.Icons.Rounded.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(28.dp) + ) + } + + else -> { + // Default/Idle icon + Icon( + imageVector = if (isShareAction) + androidx.compose.material.icons.Icons.Rounded.ReceiptLong + else + androidx.compose.material.icons.Icons.Rounded.ContentPaste, + contentDescription = "Sync", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } } From 2c9d36eef15dbafd6edd1b4d37afcc4efe9fa519 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 01:15:18 +0530 Subject: [PATCH 09/12] feat: add layout flipping functionality to RemoteControlScreen with DataStore persistence --- .../airsync/data/local/DataStoreManager.kt | 14 ++ .../ui/screens/RemoteControlScreen.kt | 158 +++++++++++++----- .../drawable/rounded_compare_arrows_24.xml | 5 + .../drawable/rounded_mobiledata_arrows_24.xml | 5 + 4 files changed, 142 insertions(+), 40 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_compare_arrows_24.xml create mode 100644 app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml 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 0e5672e..0fd5773 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 @@ -96,6 +96,8 @@ class DataStoreManager(private val context: Context) { // Widget preferences private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") + private val REMOTE_FLIPPED = booleanPreferencesKey("remote_flipped") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -604,6 +606,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setRemoteFlipped(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[REMOTE_FLIPPED] = enabled + } + } + + fun isRemoteFlipped(): Flow { + return context.dataStore.data.map { preferences -> + preferences[REMOTE_FLIPPED] ?: false + } + } + // Network-aware device connections suspend fun saveNetworkDeviceConnection( deviceName: String, 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 94bb5cf..1c7e481 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 @@ -1,7 +1,15 @@ package com.sameerasw.airsync.presentation.ui.screens import android.content.res.Configuration - +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.background @@ -41,6 +49,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -82,7 +91,12 @@ fun RemoteControlScreen( ) { val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() - LocalContext.current + val context = LocalContext.current + val dataStoreManager = remember { com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) } + val isFlipped by dataStoreManager.isRemoteFlipped().collectAsState(initial = null) + + if (isFlipped == null) return + val flippedValue = isFlipped!! fun sendRemoteAction( action: String, @@ -268,6 +282,22 @@ fun RemoteControlScreen( ) { Icon(Icons.Default.SpaceBar, "Space", modifier = Modifier.size(18.dp)) } + + OutlinedButton( + onClick = { + HapticUtil.performLightTick(haptics) + scope.launch { + dataStoreManager.setRemoteFlipped(isFlipped != true) + } + }, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Icon( + painter = painterResource(id = if (isWide) R.drawable.rounded_compare_arrows_24 else R.drawable.rounded_mobiledata_arrows_24), + contentDescription = "Flip Layout", + modifier = Modifier.size(18.dp) + ) + } } } @@ -461,48 +491,96 @@ fun RemoteControlScreen( } } - if (isWide) { - Row( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { + AnimatedContent( + targetState = flippedValue, + transitionSpec = { + if (isWide) { + if (targetState) { + (slideInHorizontally { it / 4 } + fadeIn()) togetherWith (slideOutHorizontally { -it / 4 } + fadeOut()) + } else { + (slideInHorizontally { -it / 4 } + fadeIn()) togetherWith (slideOutHorizontally { it / 4 } + fadeOut()) + } + } else { + if (targetState) { + (slideInVertically { it / 4 } + fadeIn()) togetherWith (slideOutVertically { -it / 4 } + fadeOut()) + } else { + (slideInVertically { -it / 4 } + fadeIn()) togetherWith (slideOutVertically { it / 4 } + fadeOut()) + } + }.using(SizeTransform(clip = false)) + }, + label = "RemoteLayoutFlip" + ) { flipped -> + if (isWide) { + Row( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (flipped) { + Trackpad( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + ) + Column( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + ExtraKeys() + Spacer(modifier = Modifier.height(24.dp)) + DPad() + } + } else { + Column( + modifier = Modifier + .weight(0.4f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + ExtraKeys() + Spacer(modifier = Modifier.height(24.dp)) + DPad() + } + + Trackpad( + modifier = Modifier + .weight(0.6f) + .fillMaxHeight() + ) + } + } + } else { Column( - modifier = Modifier - .weight(0.4f) - .fillMaxHeight(), + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(24.dp) ) { - ExtraKeys() - Spacer(modifier = Modifier.height(24.dp)) - DPad() + if (flipped) { + DPad() + ExtraKeys() + + Trackpad( + modifier = Modifier + .weight(1f) + ) + } else { + Trackpad( + modifier = Modifier + .weight(1f) + ) + + DPad() + ExtraKeys() + } } - - Trackpad( - modifier = Modifier - .weight(0.6f) - .fillMaxHeight() - ) - } - } else { - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Trackpad( - modifier = Modifier - .weight(1f) - ) - - DPad() - ExtraKeys() - } } } diff --git a/app/src/main/res/drawable/rounded_compare_arrows_24.xml b/app/src/main/res/drawable/rounded_compare_arrows_24.xml new file mode 100644 index 0000000..39bd25a --- /dev/null +++ b/app/src/main/res/drawable/rounded_compare_arrows_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml b/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml new file mode 100644 index 0000000..b4bcaf1 --- /dev/null +++ b/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml @@ -0,0 +1,5 @@ + + + + + From 2622f2053c47c25c568b4942a813444f9c71303b Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 01:28:29 +0530 Subject: [PATCH 10/12] feat: introduce ServiceManager to centralize and automate AirSyncService lifecycle management #95 --- .../viewmodel/AirSyncViewModel.kt | 30 ++++--------- .../sameerasw/airsync/utils/ServiceManager.kt | 45 +++++++++++++++++++ .../sameerasw/airsync/utils/WebSocketUtil.kt | 12 ++--- 3 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt 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 f6be08c..bbc5e97 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 @@ -21,6 +21,7 @@ import com.sameerasw.airsync.utils.DiscoveredDevice import com.sameerasw.airsync.utils.MacDeviceStatusManager import com.sameerasw.airsync.utils.NetworkMonitor import com.sameerasw.airsync.utils.PermissionUtil +import com.sameerasw.airsync.utils.ServiceManager import com.sameerasw.airsync.utils.SyncManager import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil @@ -369,8 +370,8 @@ class AirSyncViewModel( IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) ) - // Start AirSync Service in scanning mode (which handles UDP Discovery and WakeupService) - com.sameerasw.airsync.service.AirSyncService.startScanning(context) + // Start AirSync Service conditionally + ServiceManager.updateServiceState(context) isNetworkMonitoringActive = true } } @@ -577,6 +578,7 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isAutoReconnectEnabled = enabled) viewModelScope.launch { repository.setAutoReconnectEnabled(enabled) + appContext?.let { ServiceManager.updateServiceState(it) } } } @@ -634,15 +636,7 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isDeviceDiscoveryEnabled = enabled) viewModelScope.launch { repository.setDeviceDiscoveryEnabled(enabled) - if (enabled) { - com.sameerasw.airsync.service.AirSyncService.startScanning(context) - } else { - // When disabling discovery, we should stop discovery broadcasts - // If a connection exists, the service continues but discovery stops. - // If no connection, the service should still run for WakeupService but maybe without scanning? - // For now, let's just trigger a service update that checks the new flag. - com.sameerasw.airsync.service.AirSyncService.startScanning(context) - } + ServiceManager.updateServiceState(context) } } @@ -755,20 +749,14 @@ class AirSyncViewModel( WebSocketUtil.disconnect(context) } catch (_: Exception) { } - // Stop service when no WiFi - try { - com.sameerasw.airsync.service.AirSyncService.stop(context) - } catch (_: Exception) { - } + // Stop service if needed + ServiceManager.updateServiceState(context) _uiState.value = _uiState.value.copy(isConnected = false, isConnecting = false) return@collect } else { - // Ensure service is running when WiFi is available - try { - com.sameerasw.airsync.service.AirSyncService.startScanning(context) - } catch (_: Exception) { - } + // Ensure service state is updated + ServiceManager.updateServiceState(context) } if (target != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt new file mode 100644 index 0000000..4a15335 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt @@ -0,0 +1,45 @@ +package com.sameerasw.airsync.utils + +import android.content.Context +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.service.AirSyncService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Manages the lifecycle of background services based on connection status + * and user preferences for background features. + */ +object ServiceManager { + + /** + * Determines if any background service should be running based on settings. + */ + suspend fun shouldServiceRun(context: Context): Boolean { + val dataStore = DataStoreManager.getInstance(context) + 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 + // 3. Device discovery is enabled + return isConnected || isAutoReconnectEnabled || isDiscoveryEnabled + } + + /** + * Updates the status of the AirSyncService based on current conditions. + */ + fun updateServiceState(context: Context) { + CoroutineScope(Dispatchers.IO).launch { + if (shouldServiceRun(context)) { + AirSyncService.startScanning(context) + } else { + AirSyncService.stop(context) + } + } + } +} 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 c48d9ed..ba6e47a 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -371,9 +371,7 @@ object WebSocketUtil { handshakeTimeoutJob?.cancel() currentIpAddress = null try { - com.sameerasw.airsync.service.AirSyncService.startScanning( - context - ) + ServiceManager.updateServiceState(context) } catch (_: Exception) { } try { @@ -429,9 +427,7 @@ object WebSocketUtil { connectionAttemptJob?.cancel() currentIpAddress = null try { - com.sameerasw.airsync.service.AirSyncService.startScanning( - context - ) + ServiceManager.updateServiceState(context) } catch (_: Exception) { } try { @@ -559,9 +555,9 @@ object WebSocketUtil { // Transition back to scanning on disconnect ctx?.let { c -> try { - com.sameerasw.airsync.service.AirSyncService.startScanning(c) + ServiceManager.updateServiceState(c) } catch (e: Exception) { - Log.e(TAG, "Error starting scanning on disconnect: ${e.message}") + Log.e(TAG, "Error updating service state on disconnect: ${e.message}") } } From 1da78333454da172a47e0ec3af53f2a07f73183a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 02:16:10 +0530 Subject: [PATCH 11/12] feat: implement dynamic app shortcuts with connection-aware states --- .../com/sameerasw/airsync/MainActivity.kt | 6 +- .../ui/activities/ClipboardActionActivity.kt | 147 +++++++++++++----- .../ui/screens/AirSyncMainScreen.kt | 8 +- .../viewmodel/AirSyncViewModel.kt | 9 ++ .../airsync/service/AirSyncService.kt | 4 +- .../sameerasw/airsync/utils/ShortcutUtil.kt | 114 +++++++++++--- .../drawable/rounded_mimo_disconnect_24.xml | 5 + 7 files changed, 229 insertions(+), 64 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_mimo_disconnect_24.xml diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index bd534f9..df8c847 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -59,6 +59,7 @@ import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.KeyguardHelper import com.sameerasw.airsync.utils.NotesRoleManager import com.sameerasw.airsync.utils.PermissionUtil +import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -406,13 +407,15 @@ class MainActivity : ComponentActivity() { modifier = Modifier.padding(innerPadding) ) { composable("main") { + val initialPage = if (intent?.action == ShortcutUtil.DASH_ACTION_REMOTE) 1 else 0 AirSyncMainScreen( initialIp = ip, initialPort = port, showConnectionDialog = isFromQrScan, pcName = pcName, isPlus = isPlus, - symmetricKey = symmetricKey + symmetricKey = symmetricKey, + initialPage = initialPage ) } } @@ -559,6 +562,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) + setIntent(intent) // Handle Notes Role intent handleNotesRoleIntent(intent) 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 37a7fd7..ca00177 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 @@ -1,5 +1,6 @@ package com.sameerasw.airsync.presentation.ui.activities +import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle @@ -47,10 +48,13 @@ 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 import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.domain.model.ConnectedDevice @@ -59,6 +63,8 @@ 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 class ClipboardActionActivity : ComponentActivity() { @@ -93,7 +99,7 @@ class ClipboardActionActivity : ComponentActivity() { AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) { ClipboardActionScreen( hasWindowFocus = _windowFocus.value, - isShareAction = intent?.action == android.content.Intent.ACTION_SEND, + shortcutAction = intent?.action, onFinished = { finish() } ) } @@ -107,14 +113,15 @@ class ClipboardActionActivity : ComponentActivity() { } @Composable -fun ClipboardActionScreen( +private fun ClipboardActionScreen( hasWindowFocus: Boolean, - isShareAction: Boolean, + shortcutAction: String?, onFinished: () -> Unit ) { - val context = androidx.compose.ui.platform.LocalContext.current - val dataStoreManager = remember { DataStoreManager.getInstance(context) } - val connectedDevice by dataStoreManager.getLastConnectedDevice().collectAsState(initial = null) + val context = LocalContext.current + val viewModel: AirSyncViewModel = viewModel { AirSyncViewModel.create(context) } + val uiStateByViewModel by viewModel.uiState.collectAsState() + val connectedDevice = uiStateByViewModel.lastConnectedDevice var uiState by remember { mutableStateOf(ClipboardUiState.Loading) } var hasAttemptedSync by remember { mutableStateOf(false) } @@ -122,7 +129,7 @@ fun ClipboardActionScreen( ClipboardActionScreenContent( uiState = uiState, connectedDevice = connectedDevice, - isShareAction = isShareAction, + shortcutAction = shortcutAction, onFinished = onFinished ) @@ -132,31 +139,78 @@ fun ClipboardActionScreen( delay(100) try { - // If this is a share action, extract text from intent - val activity = context as? android.app.Activity - val intent = activity?.intent - val sharedText = if (isShareAction) { - intent?.getStringExtra(android.content.Intent.EXTRA_TEXT) - } else { - null - } + when (shortcutAction) { + ShortcutUtil.DASH_ACTION_LOCK -> { + if (WebSocketUtil.isConnected()) { + val json = org.json.JSONObject() + json.put("type", "remoteControl") + val data = org.json.JSONObject() + data.put("action", "lock_screen") + json.put("data", data) + WebSocketUtil.sendMessage(json.toString()) + uiState = ClipboardUiState.Success + } else { + uiState = ClipboardUiState.Error("Not connected") + } + delay(1200) + onFinished() + } + + ShortcutUtil.DASH_ACTION_RECONNECT -> { + val ds = DataStoreManager.getInstance(context) + ds.setUserManuallyDisconnected(false) + WebSocketUtil.requestAutoReconnect(context) + uiState = ClipboardUiState.Success + delay(1200) + onFinished() + } - // Fallback to clipboard only if not a share action or shared text is empty - val textToSync = sharedText ?: ClipboardUtil.getClipboardText(context) + ShortcutUtil.DASH_ACTION_DISCONNECT -> { + val ds = DataStoreManager.getInstance(context) + ds.setUserManuallyDisconnected(true) + WebSocketUtil.disconnect(context) + uiState = ClipboardUiState.Success + delay(1200) + onFinished() + } - if (!textToSync.isNullOrEmpty()) { - ClipboardSyncManager.syncTextToDesktop(textToSync) - uiState = ClipboardUiState.Success - delay(1200) - onFinished() - } else { - uiState = ClipboardUiState.Error( - if (isShareAction) "Shared text empty" else "Clipboard empty" - ) - delay(1500) - onFinished() + ShortcutUtil.DASH_ACTION_REMOTE -> { + val mainIntent = Intent(context, MainActivity::class.java).apply { + this.action = ShortcutUtil.DASH_ACTION_REMOTE + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(mainIntent) + onFinished() + } + + else -> { + // Default: Sync Clipboard or Shared Text + val isShareAction = shortcutAction == android.content.Intent.ACTION_SEND + val activity = context as? android.app.Activity + val intent = activity?.intent + val sharedText = if (isShareAction) { + intent?.getStringExtra(android.content.Intent.EXTRA_TEXT) + } else { + null + } + + val textToSync = sharedText ?: ClipboardUtil.getClipboardText(context) + + if (!textToSync.isNullOrEmpty()) { + ClipboardSyncManager.syncTextToDesktop(textToSync) + uiState = ClipboardUiState.Success + delay(1200) + onFinished() + } else { + uiState = ClipboardUiState.Error( + if (isShareAction) "Shared text empty" else "Clipboard empty" + ) + delay(1500) + onFinished() + } + } } - } catch (_: Exception) { + } catch (e: Exception) { uiState = ClipboardUiState.Error("Failed") delay(1500) onFinished() @@ -167,10 +221,10 @@ fun ClipboardActionScreen( @OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ClipboardActionScreenContent( +private fun ClipboardActionScreenContent( uiState: ClipboardUiState, connectedDevice: ConnectedDevice?, - isShareAction: Boolean, + shortcutAction: String?, onFinished: () -> Unit ) { // Transparent background that dismisses on click @@ -204,9 +258,16 @@ fun ClipboardActionScreenContent( tint = MaterialTheme.colorScheme.primary ) - // Device Name + // Device Name / Action + val label = when (shortcutAction) { + ShortcutUtil.DASH_ACTION_LOCK -> "Lock Mac" + ShortcutUtil.DASH_ACTION_DISCONNECT -> "Disconnected" + ShortcutUtil.DASH_ACTION_RECONNECT -> "Reconnect" + ShortcutUtil.DASH_ACTION_REMOTE -> "Opening Remote..." + else -> connectedDevice?.name ?: stringResource(R.string.your_mac) + } Text( - text = connectedDevice?.name ?: stringResource(R.string.your_mac), + text = label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) @@ -251,11 +312,17 @@ fun ClipboardActionScreenContent( else -> { // Default/Idle icon + val iconPainter = when (shortcutAction) { + ShortcutUtil.DASH_ACTION_LOCK -> painterResource(id = R.drawable.rounded_lock_24) + ShortcutUtil.DASH_ACTION_DISCONNECT -> painterResource(id = R.drawable.rounded_mimo_disconnect_24) + ShortcutUtil.DASH_ACTION_RECONNECT -> painterResource(id = R.drawable.rounded_devices_24) + ShortcutUtil.DASH_ACTION_REMOTE -> painterResource(id = R.drawable.rounded_compare_arrows_24) + ShortcutUtil.DASH_ACTION_CLIPBOARD -> painterResource(id = R.drawable.ic_clipboard_24) + android.content.Intent.ACTION_SEND -> painterResource(id = R.drawable.rounded_sync_desktop_24) + else -> painterResource(id = R.drawable.ic_clipboard_24) + } Icon( - imageVector = if (isShareAction) - androidx.compose.material.icons.Icons.Rounded.ReceiptLong - else - androidx.compose.material.icons.Icons.Rounded.ContentPaste, + painter = iconPainter, contentDescription = "Sync", modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant @@ -282,7 +349,7 @@ private fun ClipboardActionScreenPreviewLoading() { ClipboardActionScreenContent( uiState = ClipboardUiState.Loading, connectedDevice = null, - isShareAction = false, + shortcutAction = null, onFinished = {}) } } @@ -294,7 +361,7 @@ private fun ClipboardActionScreenPreviewSuccess() { ClipboardActionScreenContent( uiState = ClipboardUiState.Success, connectedDevice = null, - isShareAction = false, + shortcutAction = null, onFinished = {}) } } @@ -306,7 +373,7 @@ private fun ClipboardActionScreenPreviewError() { ClipboardActionScreenContent( uiState = ClipboardUiState.Error("Failed to sync"), connectedDevice = null, - isShareAction = false, + shortcutAction = null, onFinished = {}) } } 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 b6937df..8027e0d 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 @@ -144,6 +144,7 @@ fun AirSyncMainScreen( pcName: String? = null, isPlus: Boolean = false, symmetricKey: String? = null, + initialPage: Int = 0, onNavigateToApps: () -> Unit = {}, onTitleChange: (String) -> Unit = {} ) { @@ -217,7 +218,7 @@ fun AirSyncMainScreen( } } val pagerState = - rememberPagerState(initialPage = 0, pageCount = { if (uiState.isConnected) 4 else 2 }) + rememberPagerState(initialPage = initialPage, pageCount = { if (uiState.isConnected) 4 else 2 }) val navCallbackState = rememberUpdatedState(onNavigateToApps) LaunchedEffect(navCallbackState.value) { } @@ -231,6 +232,11 @@ fun AirSyncMainScreen( // Initial tab navigation logic LaunchedEffect(Unit) { if (!hasAppliedInitialTab) { + if (initialPage != 0) { + hasAppliedInitialTab = true + return@LaunchedEffect + } + // Wait up to 2 seconds for initial connection (e.g. auto-reconnect on start) withTimeoutOrNull(2000) { snapshotFlow { uiState.isConnected }.filter { it }.first() 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 bbc5e97..bd8ba0f 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 @@ -22,6 +22,7 @@ import com.sameerasw.airsync.utils.MacDeviceStatusManager import com.sameerasw.airsync.utils.NetworkMonitor import com.sameerasw.airsync.utils.PermissionUtil import com.sameerasw.airsync.utils.ServiceManager +import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.SyncManager import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil @@ -108,6 +109,11 @@ class AirSyncViewModel( updateRatingPromptDisplay() } + // Update dynamic shortcuts + appContext?.let { ctx -> + ShortcutUtil.refreshShortcuts(ctx, isConnected) + } + // Notify Smartspacer of connection status change appContext?.let { context -> try { @@ -372,6 +378,9 @@ class AirSyncViewModel( // Start AirSync Service conditionally ServiceManager.updateServiceState(context) + + // Initial shortcut state + ShortcutUtil.refreshShortcuts(context, WebSocketUtil.isConnected()) isNetworkMonitoringActive = true } } 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 6ba5375..f5d4065 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -58,7 +58,7 @@ class AirSyncService : Service() { ACTION_START_SYNC -> { connectedDeviceName = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "Mac" startSync() - com.sameerasw.airsync.utils.ShortcutUtil.updateShareShortcut(this, connectedDeviceName) + com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, true) } ACTION_STOP_SYNC -> stopSync() @@ -138,9 +138,9 @@ class AirSyncService : Service() { private fun stopSync() { Log.d(TAG, "Stopping AirSync foreground service") + com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, false) UDPDiscoveryManager.stop(this) WakeupService.stopService(this) - com.sameerasw.airsync.utils.ShortcutUtil.removeShareShortcut(this) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } 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 2a8f62b..c1235d7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt @@ -9,30 +9,104 @@ import com.sameerasw.airsync.R import com.sameerasw.airsync.presentation.ui.activities.ClipboardActionActivity object ShortcutUtil { - private const val SHORTCUT_ID_SEND_TO_MAC = "shortcut_send_to_mac" - - fun updateShareShortcut(context: Context, macName: String?) { - val macDisplayName = macName ?: context.getString(R.string.your_mac) - - val shortcut = ShortcutInfoCompat.Builder(context, SHORTCUT_ID_SEND_TO_MAC) - .setShortLabel(macDisplayName) - .setLongLabel(context.getString(R.string.tab_clipboard) + " - " + macDisplayName) - .setIcon(IconCompat.createWithResource(context, R.drawable.ic_laptop_24)) - .setIntent( - Intent(context, ClipboardActionActivity::class.java).apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, "") // Placeholder - addCategory(Intent.CATEGORY_DEFAULT) - } + const val SHORTCUT_ID_SCAN = "shortcut_scan" + const val SHORTCUT_ID_RECONNECT = "shortcut_reconnect" + const val SHORTCUT_ID_CLIPBOARD = "shortcut_clipboard" + const val SHORTCUT_ID_LOCK = "shortcut_lock" + const val SHORTCUT_ID_REMOTE = "shortcut_remote" + const val SHORTCUT_ID_DISCONNECT = "shortcut_disconnect" + + const val DASH_ACTION_RECONNECT = "com.sameerasw.airsync.ACTION_RECONNECT" + const val DASH_ACTION_CLIPBOARD = "com.sameerasw.airsync.ACTION_CLIPBOARD" + const val DASH_ACTION_LOCK = "com.sameerasw.airsync.ACTION_LOCK" + const val DASH_ACTION_REMOTE = "com.sameerasw.airsync.ACTION_REMOTE" + const val DASH_ACTION_DISCONNECT = "com.sameerasw.airsync.ACTION_DISCONNECT" + + fun refreshShortcuts(context: Context, isConnected: Boolean) { + val shortcuts = mutableListOf() + + if (!isConnected) { + // 1. Scan (Only when disconnected) + shortcuts.add( + 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" + }) + .build() + ) + + // 2. Reconnect + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_RECONNECT) + .setShortLabel("Reconnect") + .setLongLabel("Reconnect") + .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_devices_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_RECONNECT + }) + .build() + ) + } else { + // 1. Remote (Direct to Remote tab) + // Use MainActivity with a specific action + shortcuts.add( + 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 + }) + .build() + ) + + // 2. Clipboard + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_CLIPBOARD) + .setShortLabel("Clipboard") + .setLongLabel("Send Clipboard") + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_clipboard_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_CLIPBOARD + }) + .build() + ) + + // 3. Lock + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_LOCK) + .setShortLabel("Lock") + .setLongLabel("Lock Mac") + .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_lock_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_LOCK + }) + .build() + ) + + // 4. Disconnect + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_DISCONNECT) + .setShortLabel("Disconnect") + .setLongLabel("Disconnect") + .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_mimo_disconnect_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_DISCONNECT + }) + .build() ) - .setCategories(setOf("com.sameerasw.airsync.categories.SHARE_TARGET")) - .setLongLived(true) - .build() + } - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + // Set dynamic shortcuts (replaces existing ones) + ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) } + // Legacy method cleanup fun removeShareShortcut(context: Context) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(SHORTCUT_ID_SEND_TO_MAC)) + ShortcutManagerCompat.removeAllDynamicShortcuts(context) } } diff --git a/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml b/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml new file mode 100644 index 0000000..c007dca --- /dev/null +++ b/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml @@ -0,0 +1,5 @@ + + + + + From 0f9cc15968fa0d1608be34872fcffb9daf6dfa56 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 11 Apr 2026 02:18:46 +0530 Subject: [PATCH 12/12] version: Updated to v3.1.0 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45ed1bc..db4c146 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.sameerasw.airsync" minSdk = 30 targetSdk = 36 - versionCode = 26 - versionName = "3.0.0" + versionCode = 27 + versionName = "3.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }