diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 45ed1bc1..db4c1464 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"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 70324cd3..973848ef 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -83,10 +83,13 @@
-
+
+
@@ -94,10 +97,16 @@
+ android:taskAffinity="">
+
+
+
+
+
+
{
return discovery?.getDiscoveredServices() ?: emptyList()
}
@@ -395,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
)
}
}
@@ -548,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/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
index 0e5672e4..0fd57735 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/activities/ClipboardActionActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt
index 103a07f5..ca00177b 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
@@ -17,6 +18,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 +28,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
@@ -45,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
@@ -57,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() {
@@ -91,6 +99,7 @@ class ClipboardActionActivity : ComponentActivity() {
AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
ClipboardActionScreen(
hasWindowFocus = _windowFocus.value,
+ shortcutAction = intent?.action,
onFinished = { finish() }
)
}
@@ -104,10 +113,15 @@ class ClipboardActionActivity : ComponentActivity() {
}
@Composable
-fun ClipboardActionScreen(hasWindowFocus: 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)
+private fun ClipboardActionScreen(
+ hasWindowFocus: Boolean,
+ shortcutAction: String?,
+ onFinished: () -> Unit
+) {
+ 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) }
@@ -115,30 +129,88 @@ fun ClipboardActionScreen(hasWindowFocus: Boolean, onFinished: () -> Unit) {
ClipboardActionScreenContent(
uiState = uiState,
connectedDevice = connectedDevice,
+ shortcutAction = shortcutAction,
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)
+ 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()
+ }
+
+ ShortcutUtil.DASH_ACTION_DISCONNECT -> {
+ val ds = DataStoreManager.getInstance(context)
+ ds.setUserManuallyDisconnected(true)
+ WebSocketUtil.disconnect(context)
+ uiState = ClipboardUiState.Success
+ delay(1200)
+ 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 (!clipboardText.isNullOrEmpty()) {
- ClipboardSyncManager.syncTextToDesktop(clipboardText)
- uiState = ClipboardUiState.Success
- delay(1200) // Show success for 1.2s
- onFinished()
- } else {
- uiState = ClipboardUiState.Error("Clipboard empty")
- delay(1500)
- 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()
+ }
+ }
}
- } catch (_: Exception) {
+ } catch (e: Exception) {
uiState = ClipboardUiState.Error("Failed")
delay(1500)
onFinished()
@@ -149,116 +221,114 @@ fun ClipboardActionScreen(hasWindowFocus: Boolean, onFinished: () -> Unit) {
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
-fun ClipboardActionScreenContent(
+private fun ClipboardActionScreenContent(
uiState: ClipboardUiState,
connectedDevice: ConnectedDevice?,
+ shortcutAction: String?,
onFinished: () -> Unit
) {
// Transparent background that dismisses on click
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 / 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 = label,
+ 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
- )
- }
+ when (state) {
+ is ClipboardUiState.Loading -> {
+ LoadingIndicator(
+ modifier = Modifier.size(24.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.Success -> {
+ Icon(
+ imageVector = androidx.compose.material.icons.Icons.Rounded.CheckCircle,
+ contentDescription = "Success",
+ modifier = Modifier.size(28.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
- is ClipboardUiState.Error -> {
- Icon(
- imageVector = Icons.Rounded.Error,
- contentDescription = "Error",
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier.size(56.dp)
- )
- }
+ 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
+ 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(
+ painter = iconPainter,
+ contentDescription = "Sync",
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
}
-
- 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)
- )
- .padding(horizontal = 16.dp, vertical = 4.dp),
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // 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)
- },
- style = MaterialTheme.typography.titleSmall,
- color = if (state is ClipboardUiState.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
- )
}
}
}
@@ -279,6 +349,7 @@ private fun ClipboardActionScreenPreviewLoading() {
ClipboardActionScreenContent(
uiState = ClipboardUiState.Loading,
connectedDevice = null,
+ shortcutAction = null,
onFinished = {})
}
}
@@ -290,6 +361,7 @@ private fun ClipboardActionScreenPreviewSuccess() {
ClipboardActionScreenContent(
uiState = ClipboardUiState.Success,
connectedDevice = null,
+ shortcutAction = null,
onFinished = {})
}
}
@@ -301,6 +373,7 @@ private fun ClipboardActionScreenPreviewError() {
ClipboardActionScreenContent(
uiState = ClipboardUiState.Error("Failed to sync"),
connectedDevice = null,
+ shortcutAction = null,
onFinished = {})
}
}
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 c10fdac1..5ebc9dc6 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),
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 d843cdbd..860c2a2b 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
@@ -3,7 +3,12 @@ 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
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
@@ -72,12 +77,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 +93,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 06d54097..8027e0dc 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()
@@ -719,20 +725,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 ->
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 94bb5cfd..1c7e4819 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/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt
index f6be08cc..bd8ba0fe 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,8 @@ 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.ShortcutUtil
import com.sameerasw.airsync.utils.SyncManager
import com.sameerasw.airsync.utils.UDPDiscoveryManager
import com.sameerasw.airsync.utils.WebSocketUtil
@@ -107,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 {
@@ -369,8 +376,11 @@ 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)
+
+ // Initial shortcut state
+ ShortcutUtil.refreshShortcuts(context, WebSocketUtil.isConnected())
isNetworkMonitoringActive = true
}
}
@@ -577,6 +587,7 @@ class AirSyncViewModel(
_uiState.value = _uiState.value.copy(isAutoReconnectEnabled = enabled)
viewModelScope.launch {
repository.setAutoReconnectEnabled(enabled)
+ appContext?.let { ServiceManager.updateServiceState(it) }
}
}
@@ -634,15 +645,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 +758,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/quickshare/QuickShareService.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareService.kt
index bbeb37a0..4d216972 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
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 002e36a5..f5d40659 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.refreshShortcuts(this, true)
}
ACTION_STOP_SYNC -> stopSync()
@@ -137,6 +138,7 @@ 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)
stopForeground(STOP_FOREGROUND_REMOVE)
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 b89fb3cc..f24d088e 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/ServiceManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/ServiceManager.kt
new file mode 100644
index 00000000..4a15335b
--- /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/ShortcutUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt
new file mode 100644
index 00000000..c1235d7f
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt
@@ -0,0 +1,112 @@
+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 {
+ 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()
+ )
+ }
+
+ // Set dynamic shortcuts (replaces existing ones)
+ ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
+ }
+
+ // Legacy method cleanup
+ fun removeShareShortcut(context: Context) {
+ ShortcutManagerCompat.removeAllDynamicShortcuts(context)
+ }
+}
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 852e5ce2..3da42518 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/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt
index 237e8126..73a240b4 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/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt
index 12dca51a..441c4820 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 {
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 1e59e686..ba6e47ae 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)
@@ -361,9 +371,7 @@ object WebSocketUtil {
handshakeTimeoutJob?.cancel()
currentIpAddress = null
try {
- com.sameerasw.airsync.service.AirSyncService.startScanning(
- context
- )
+ ServiceManager.updateServiceState(context)
} catch (_: Exception) {
}
try {
@@ -373,7 +381,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) {
@@ -414,9 +427,7 @@ object WebSocketUtil {
connectionAttemptJob?.cancel()
currentIpAddress = null
try {
- com.sameerasw.airsync.service.AirSyncService.startScanning(
- context
- )
+ ServiceManager.updateServiceState(context)
} catch (_: Exception) {
}
try {
@@ -426,7 +437,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,19 +537,27 @@ 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)
+ 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}")
}
}
@@ -639,42 +671,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 +780,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 +788,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 +797,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()
}
}
}
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 00000000..39bd25ab
--- /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_mimo_disconnect_24.xml b/app/src/main/res/drawable/rounded_mimo_disconnect_24.xml
new file mode 100644
index 00000000..c007dca1
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_mimo_disconnect_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 00000000..b4bcaf1f
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_mobiledata_arrows_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 00000000..e5ef4db7
--- /dev/null
+++ b/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+