Skip to content

Commit 1da7833

Browse files
committed
feat: implement dynamic app shortcuts with connection-aware states
1 parent 2622f20 commit 1da7833

7 files changed

Lines changed: 229 additions & 64 deletions

File tree

app/src/main/java/com/sameerasw/airsync/MainActivity.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import com.sameerasw.airsync.utils.DevicePreviewResolver
5959
import com.sameerasw.airsync.utils.KeyguardHelper
6060
import com.sameerasw.airsync.utils.NotesRoleManager
6161
import com.sameerasw.airsync.utils.PermissionUtil
62+
import com.sameerasw.airsync.utils.ShortcutUtil
6263
import com.sameerasw.airsync.utils.WebSocketUtil
6364
import kotlinx.coroutines.flow.first
6465
import kotlinx.coroutines.runBlocking
@@ -406,13 +407,15 @@ class MainActivity : ComponentActivity() {
406407
modifier = Modifier.padding(innerPadding)
407408
) {
408409
composable("main") {
410+
val initialPage = if (intent?.action == ShortcutUtil.DASH_ACTION_REMOTE) 1 else 0
409411
AirSyncMainScreen(
410412
initialIp = ip,
411413
initialPort = port,
412414
showConnectionDialog = isFromQrScan,
413415
pcName = pcName,
414416
isPlus = isPlus,
415-
symmetricKey = symmetricKey
417+
symmetricKey = symmetricKey,
418+
initialPage = initialPage
416419
)
417420
}
418421
}
@@ -559,6 +562,7 @@ class MainActivity : ComponentActivity() {
559562

560563
override fun onNewIntent(intent: Intent?) {
561564
super.onNewIntent(intent)
565+
setIntent(intent)
562566

563567
// Handle Notes Role intent
564568
handleNotesRoleIntent(intent)

app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt

Lines changed: 107 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sameerasw.airsync.presentation.ui.activities
22

3+
import android.content.Intent
34
import android.graphics.Color
45
import android.graphics.drawable.ColorDrawable
56
import android.os.Bundle
@@ -47,10 +48,13 @@ import androidx.compose.runtime.setValue
4748
import androidx.compose.ui.Alignment
4849
import androidx.compose.ui.Modifier
4950
import androidx.compose.ui.layout.ContentScale
51+
import androidx.compose.ui.platform.LocalContext
5052
import androidx.compose.ui.res.painterResource
5153
import androidx.compose.ui.res.stringResource
5254
import androidx.compose.ui.tooling.preview.Preview
5355
import androidx.compose.ui.unit.dp
56+
import androidx.lifecycle.viewmodel.compose.viewModel
57+
import com.sameerasw.airsync.MainActivity
5458
import com.sameerasw.airsync.R
5559
import com.sameerasw.airsync.data.local.DataStoreManager
5660
import com.sameerasw.airsync.domain.model.ConnectedDevice
@@ -59,6 +63,8 @@ import com.sameerasw.airsync.ui.theme.AirSyncTheme
5963
import com.sameerasw.airsync.utils.ClipboardSyncManager
6064
import com.sameerasw.airsync.utils.ClipboardUtil
6165
import com.sameerasw.airsync.utils.DevicePreviewResolver
66+
import com.sameerasw.airsync.utils.ShortcutUtil
67+
import com.sameerasw.airsync.utils.WebSocketUtil
6268
import kotlinx.coroutines.delay
6369

6470
class ClipboardActionActivity : ComponentActivity() {
@@ -93,7 +99,7 @@ class ClipboardActionActivity : ComponentActivity() {
9399
AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
94100
ClipboardActionScreen(
95101
hasWindowFocus = _windowFocus.value,
96-
isShareAction = intent?.action == android.content.Intent.ACTION_SEND,
102+
shortcutAction = intent?.action,
97103
onFinished = { finish() }
98104
)
99105
}
@@ -107,22 +113,23 @@ class ClipboardActionActivity : ComponentActivity() {
107113
}
108114

109115
@Composable
110-
fun ClipboardActionScreen(
116+
private fun ClipboardActionScreen(
111117
hasWindowFocus: Boolean,
112-
isShareAction: Boolean,
118+
shortcutAction: String?,
113119
onFinished: () -> Unit
114120
) {
115-
val context = androidx.compose.ui.platform.LocalContext.current
116-
val dataStoreManager = remember { DataStoreManager.getInstance(context) }
117-
val connectedDevice by dataStoreManager.getLastConnectedDevice().collectAsState(initial = null)
121+
val context = LocalContext.current
122+
val viewModel: AirSyncViewModel = viewModel { AirSyncViewModel.create(context) }
123+
val uiStateByViewModel by viewModel.uiState.collectAsState()
124+
val connectedDevice = uiStateByViewModel.lastConnectedDevice
118125

119126
var uiState by remember { mutableStateOf<ClipboardUiState>(ClipboardUiState.Loading) }
120127
var hasAttemptedSync by remember { mutableStateOf(false) }
121128

122129
ClipboardActionScreenContent(
123130
uiState = uiState,
124131
connectedDevice = connectedDevice,
125-
isShareAction = isShareAction,
132+
shortcutAction = shortcutAction,
126133
onFinished = onFinished
127134
)
128135

@@ -132,31 +139,78 @@ fun ClipboardActionScreen(
132139
delay(100)
133140

134141
try {
135-
// If this is a share action, extract text from intent
136-
val activity = context as? android.app.Activity
137-
val intent = activity?.intent
138-
val sharedText = if (isShareAction) {
139-
intent?.getStringExtra(android.content.Intent.EXTRA_TEXT)
140-
} else {
141-
null
142-
}
142+
when (shortcutAction) {
143+
ShortcutUtil.DASH_ACTION_LOCK -> {
144+
if (WebSocketUtil.isConnected()) {
145+
val json = org.json.JSONObject()
146+
json.put("type", "remoteControl")
147+
val data = org.json.JSONObject()
148+
data.put("action", "lock_screen")
149+
json.put("data", data)
150+
WebSocketUtil.sendMessage(json.toString())
151+
uiState = ClipboardUiState.Success
152+
} else {
153+
uiState = ClipboardUiState.Error("Not connected")
154+
}
155+
delay(1200)
156+
onFinished()
157+
}
158+
159+
ShortcutUtil.DASH_ACTION_RECONNECT -> {
160+
val ds = DataStoreManager.getInstance(context)
161+
ds.setUserManuallyDisconnected(false)
162+
WebSocketUtil.requestAutoReconnect(context)
163+
uiState = ClipboardUiState.Success
164+
delay(1200)
165+
onFinished()
166+
}
143167

144-
// Fallback to clipboard only if not a share action or shared text is empty
145-
val textToSync = sharedText ?: ClipboardUtil.getClipboardText(context)
168+
ShortcutUtil.DASH_ACTION_DISCONNECT -> {
169+
val ds = DataStoreManager.getInstance(context)
170+
ds.setUserManuallyDisconnected(true)
171+
WebSocketUtil.disconnect(context)
172+
uiState = ClipboardUiState.Success
173+
delay(1200)
174+
onFinished()
175+
}
146176

147-
if (!textToSync.isNullOrEmpty()) {
148-
ClipboardSyncManager.syncTextToDesktop(textToSync)
149-
uiState = ClipboardUiState.Success
150-
delay(1200)
151-
onFinished()
152-
} else {
153-
uiState = ClipboardUiState.Error(
154-
if (isShareAction) "Shared text empty" else "Clipboard empty"
155-
)
156-
delay(1500)
157-
onFinished()
177+
ShortcutUtil.DASH_ACTION_REMOTE -> {
178+
val mainIntent = Intent(context, MainActivity::class.java).apply {
179+
this.action = ShortcutUtil.DASH_ACTION_REMOTE
180+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
181+
}
182+
context.startActivity(mainIntent)
183+
onFinished()
184+
}
185+
186+
else -> {
187+
// Default: Sync Clipboard or Shared Text
188+
val isShareAction = shortcutAction == android.content.Intent.ACTION_SEND
189+
val activity = context as? android.app.Activity
190+
val intent = activity?.intent
191+
val sharedText = if (isShareAction) {
192+
intent?.getStringExtra(android.content.Intent.EXTRA_TEXT)
193+
} else {
194+
null
195+
}
196+
197+
val textToSync = sharedText ?: ClipboardUtil.getClipboardText(context)
198+
199+
if (!textToSync.isNullOrEmpty()) {
200+
ClipboardSyncManager.syncTextToDesktop(textToSync)
201+
uiState = ClipboardUiState.Success
202+
delay(1200)
203+
onFinished()
204+
} else {
205+
uiState = ClipboardUiState.Error(
206+
if (isShareAction) "Shared text empty" else "Clipboard empty"
207+
)
208+
delay(1500)
209+
onFinished()
210+
}
211+
}
158212
}
159-
} catch (_: Exception) {
213+
} catch (e: Exception) {
160214
uiState = ClipboardUiState.Error("Failed")
161215
delay(1500)
162216
onFinished()
@@ -167,10 +221,10 @@ fun ClipboardActionScreen(
167221

168222
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
169223
@Composable
170-
fun ClipboardActionScreenContent(
224+
private fun ClipboardActionScreenContent(
171225
uiState: ClipboardUiState,
172226
connectedDevice: ConnectedDevice?,
173-
isShareAction: Boolean,
227+
shortcutAction: String?,
174228
onFinished: () -> Unit
175229
) {
176230
// Transparent background that dismisses on click
@@ -204,9 +258,16 @@ fun ClipboardActionScreenContent(
204258
tint = MaterialTheme.colorScheme.primary
205259
)
206260

207-
// Device Name
261+
// Device Name / Action
262+
val label = when (shortcutAction) {
263+
ShortcutUtil.DASH_ACTION_LOCK -> "Lock Mac"
264+
ShortcutUtil.DASH_ACTION_DISCONNECT -> "Disconnected"
265+
ShortcutUtil.DASH_ACTION_RECONNECT -> "Reconnect"
266+
ShortcutUtil.DASH_ACTION_REMOTE -> "Opening Remote..."
267+
else -> connectedDevice?.name ?: stringResource(R.string.your_mac)
268+
}
208269
Text(
209-
text = connectedDevice?.name ?: stringResource(R.string.your_mac),
270+
text = label,
210271
style = MaterialTheme.typography.bodyLarge,
211272
color = MaterialTheme.colorScheme.onSurface
212273
)
@@ -251,11 +312,17 @@ fun ClipboardActionScreenContent(
251312

252313
else -> {
253314
// Default/Idle icon
315+
val iconPainter = when (shortcutAction) {
316+
ShortcutUtil.DASH_ACTION_LOCK -> painterResource(id = R.drawable.rounded_lock_24)
317+
ShortcutUtil.DASH_ACTION_DISCONNECT -> painterResource(id = R.drawable.rounded_mimo_disconnect_24)
318+
ShortcutUtil.DASH_ACTION_RECONNECT -> painterResource(id = R.drawable.rounded_devices_24)
319+
ShortcutUtil.DASH_ACTION_REMOTE -> painterResource(id = R.drawable.rounded_compare_arrows_24)
320+
ShortcutUtil.DASH_ACTION_CLIPBOARD -> painterResource(id = R.drawable.ic_clipboard_24)
321+
android.content.Intent.ACTION_SEND -> painterResource(id = R.drawable.rounded_sync_desktop_24)
322+
else -> painterResource(id = R.drawable.ic_clipboard_24)
323+
}
254324
Icon(
255-
imageVector = if (isShareAction)
256-
androidx.compose.material.icons.Icons.Rounded.ReceiptLong
257-
else
258-
androidx.compose.material.icons.Icons.Rounded.ContentPaste,
325+
painter = iconPainter,
259326
contentDescription = "Sync",
260327
modifier = Modifier.size(24.dp),
261328
tint = MaterialTheme.colorScheme.onSurfaceVariant
@@ -282,7 +349,7 @@ private fun ClipboardActionScreenPreviewLoading() {
282349
ClipboardActionScreenContent(
283350
uiState = ClipboardUiState.Loading,
284351
connectedDevice = null,
285-
isShareAction = false,
352+
shortcutAction = null,
286353
onFinished = {})
287354
}
288355
}
@@ -294,7 +361,7 @@ private fun ClipboardActionScreenPreviewSuccess() {
294361
ClipboardActionScreenContent(
295362
uiState = ClipboardUiState.Success,
296363
connectedDevice = null,
297-
isShareAction = false,
364+
shortcutAction = null,
298365
onFinished = {})
299366
}
300367
}
@@ -306,7 +373,7 @@ private fun ClipboardActionScreenPreviewError() {
306373
ClipboardActionScreenContent(
307374
uiState = ClipboardUiState.Error("Failed to sync"),
308375
connectedDevice = null,
309-
isShareAction = false,
376+
shortcutAction = null,
310377
onFinished = {})
311378
}
312379
}

app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ fun AirSyncMainScreen(
144144
pcName: String? = null,
145145
isPlus: Boolean = false,
146146
symmetricKey: String? = null,
147+
initialPage: Int = 0,
147148
onNavigateToApps: () -> Unit = {},
148149
onTitleChange: (String) -> Unit = {}
149150
) {
@@ -217,7 +218,7 @@ fun AirSyncMainScreen(
217218
}
218219
}
219220
val pagerState =
220-
rememberPagerState(initialPage = 0, pageCount = { if (uiState.isConnected) 4 else 2 })
221+
rememberPagerState(initialPage = initialPage, pageCount = { if (uiState.isConnected) 4 else 2 })
221222
val navCallbackState = rememberUpdatedState(onNavigateToApps)
222223
LaunchedEffect(navCallbackState.value) {
223224
}
@@ -231,6 +232,11 @@ fun AirSyncMainScreen(
231232
// Initial tab navigation logic
232233
LaunchedEffect(Unit) {
233234
if (!hasAppliedInitialTab) {
235+
if (initialPage != 0) {
236+
hasAppliedInitialTab = true
237+
return@LaunchedEffect
238+
}
239+
234240
// Wait up to 2 seconds for initial connection (e.g. auto-reconnect on start)
235241
withTimeoutOrNull(2000) {
236242
snapshotFlow { uiState.isConnected }.filter { it }.first()

app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.sameerasw.airsync.utils.MacDeviceStatusManager
2222
import com.sameerasw.airsync.utils.NetworkMonitor
2323
import com.sameerasw.airsync.utils.PermissionUtil
2424
import com.sameerasw.airsync.utils.ServiceManager
25+
import com.sameerasw.airsync.utils.ShortcutUtil
2526
import com.sameerasw.airsync.utils.SyncManager
2627
import com.sameerasw.airsync.utils.UDPDiscoveryManager
2728
import com.sameerasw.airsync.utils.WebSocketUtil
@@ -108,6 +109,11 @@ class AirSyncViewModel(
108109
updateRatingPromptDisplay()
109110
}
110111

112+
// Update dynamic shortcuts
113+
appContext?.let { ctx ->
114+
ShortcutUtil.refreshShortcuts(ctx, isConnected)
115+
}
116+
111117
// Notify Smartspacer of connection status change
112118
appContext?.let { context ->
113119
try {
@@ -372,6 +378,9 @@ class AirSyncViewModel(
372378

373379
// Start AirSync Service conditionally
374380
ServiceManager.updateServiceState(context)
381+
382+
// Initial shortcut state
383+
ShortcutUtil.refreshShortcuts(context, WebSocketUtil.isConnected())
375384
isNetworkMonitoringActive = true
376385
}
377386
}

app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class AirSyncService : Service() {
5858
ACTION_START_SYNC -> {
5959
connectedDeviceName = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "Mac"
6060
startSync()
61-
com.sameerasw.airsync.utils.ShortcutUtil.updateShareShortcut(this, connectedDeviceName)
61+
com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, true)
6262
}
6363

6464
ACTION_STOP_SYNC -> stopSync()
@@ -138,9 +138,9 @@ class AirSyncService : Service() {
138138

139139
private fun stopSync() {
140140
Log.d(TAG, "Stopping AirSync foreground service")
141+
com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, false)
141142
UDPDiscoveryManager.stop(this)
142143
WakeupService.stopService(this)
143-
com.sameerasw.airsync.utils.ShortcutUtil.removeShareShortcut(this)
144144
stopForeground(STOP_FOREGROUND_REMOVE)
145145
stopSelf()
146146
}

0 commit comments

Comments
 (0)