Skip to content

Commit d1fbcaf

Browse files
authored
Merge pull request #79 from sameerasw/develop
Develop - Clipboard tile and dicovery improvements
2 parents 7f88203 + 26f1ade commit d1fbcaf

15 files changed

Lines changed: 753 additions & 30 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
1717
<uses-permission android:name="android.permission.READ_WALLPAPER_INTERNAL" />
1818

19+
1920
<!-- Permission for downloading updates -->
2021
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
2122

@@ -106,6 +107,14 @@
106107
</intent-filter>
107108
</activity>
108109

110+
<activity
111+
android:name=".presentation.ui.activities.ClipboardActionActivity"
112+
android:theme="@style/Theme.AirSync.Transparent"
113+
android:exported="false"
114+
android:excludeFromRecents="true"
115+
android:noHistory="true"
116+
android:taskAffinity="" />
117+
109118
<service
110119
android:name=".service.MediaNotificationListener"
111120
android:exported="false"
@@ -155,6 +164,18 @@
155164
</intent-filter>
156165
</service>
157166

167+
<!-- Clipboard Quick Settings Tile Service -->
168+
<service
169+
android:name=".service.ClipboardTileService"
170+
android:exported="true"
171+
android:icon="@drawable/ic_clipboard_24"
172+
android:label="Send Clipboard"
173+
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
174+
<intent-filter>
175+
<action android:name="android.service.quicksettings.action.QS_TILE" />
176+
</intent-filter>
177+
</service>
178+
158179
<!-- Wake-up Service for receiving reconnection requests from Mac -->
159180
<service
160181
android:name=".service.WakeupService"
@@ -229,8 +250,11 @@
229250
<action android:name="com.sameerasw.airsync.action.REQUEST_MAC_BATTERY" />
230251
</intent-filter>
231252
</receiver>
253+
254+
232255
</application>
233256

257+
234258
<queries>
235259
<!-- To see launchable apps -->
236260
<intent>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ class MainActivity : ComponentActivity() {
142142
}
143143
}
144144

145+
override fun onStart() {
146+
super.onStart()
147+
com.sameerasw.airsync.service.AirSyncService.notifyAppForeground(this)
148+
}
149+
150+
override fun onStop() {
151+
super.onStop()
152+
com.sameerasw.airsync.service.AirSyncService.notifyAppBackground(this)
153+
}
154+
145155
@OptIn(ExperimentalMaterial3Api::class)
146156
override fun onCreate(savedInstanceState: Bundle?) {
147157
// Install and configure the splash screen before any UI rendering
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package com.sameerasw.airsync.presentation.ui.activities
2+
3+
import android.graphics.Color
4+
import android.graphics.drawable.ColorDrawable
5+
import android.os.Bundle
6+
import androidx.activity.ComponentActivity
7+
import androidx.activity.compose.setContent
8+
import androidx.activity.enableEdgeToEdge
9+
import androidx.activity.SystemBarStyle
10+
import androidx.compose.animation.AnimatedContent
11+
import androidx.compose.animation.ExperimentalAnimationApi
12+
import androidx.compose.animation.fadeIn
13+
import androidx.compose.animation.fadeOut
14+
import androidx.compose.animation.togetherWith
15+
import androidx.compose.foundation.Image
16+
import androidx.compose.foundation.background
17+
import androidx.compose.foundation.border
18+
import androidx.compose.foundation.clickable
19+
import androidx.compose.foundation.layout.Box
20+
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.Spacer
22+
import androidx.compose.foundation.layout.fillMaxSize
23+
import androidx.compose.foundation.layout.fillMaxWidth
24+
import androidx.compose.foundation.layout.height
25+
import androidx.compose.foundation.layout.navigationBarsPadding
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.foundation.layout.size
28+
import androidx.compose.foundation.shape.CircleShape
29+
import androidx.compose.foundation.shape.RoundedCornerShape
30+
import androidx.compose.material.icons.Icons
31+
import androidx.compose.material.icons.rounded.CheckCircle
32+
import androidx.compose.material.icons.rounded.Error
33+
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
34+
import androidx.compose.material3.Icon
35+
import androidx.compose.material3.LoadingIndicator
36+
import androidx.compose.material3.MaterialTheme
37+
import androidx.compose.material3.Surface
38+
import androidx.compose.material3.Text
39+
import androidx.compose.runtime.Composable
40+
import androidx.compose.runtime.LaunchedEffect
41+
import androidx.compose.runtime.collectAsState
42+
import androidx.compose.runtime.getValue
43+
import androidx.compose.runtime.mutableStateOf
44+
import androidx.compose.runtime.remember
45+
import androidx.compose.runtime.setValue
46+
import androidx.compose.ui.Alignment
47+
import androidx.compose.ui.Modifier
48+
import androidx.compose.ui.draw.clip
49+
import androidx.compose.ui.layout.ContentScale
50+
import androidx.compose.ui.res.painterResource
51+
import androidx.compose.ui.res.stringResource
52+
import androidx.compose.ui.text.font.FontWeight
53+
import androidx.compose.ui.tooling.preview.Preview
54+
import androidx.compose.ui.unit.dp
55+
import com.sameerasw.airsync.R
56+
import com.sameerasw.airsync.data.local.DataStoreManager
57+
import com.sameerasw.airsync.domain.model.ConnectedDevice
58+
import com.sameerasw.airsync.ui.theme.AirSyncTheme
59+
import com.sameerasw.airsync.utils.ClipboardSyncManager
60+
import com.sameerasw.airsync.utils.ClipboardUtil
61+
import com.sameerasw.airsync.utils.DevicePreviewResolver
62+
import kotlinx.coroutines.delay
63+
64+
class ClipboardActionActivity : ComponentActivity() {
65+
66+
private val _windowFocus = mutableStateOf(false)
67+
68+
override fun onCreate(savedInstanceState: Bundle?) {
69+
super.onCreate(savedInstanceState)
70+
71+
// Standard Edge-to-Edge with explicit transparent bars
72+
enableEdgeToEdge(
73+
statusBarStyle = SystemBarStyle.auto(
74+
Color.TRANSPARENT,
75+
Color.TRANSPARENT
76+
),
77+
navigationBarStyle = SystemBarStyle.auto(
78+
Color.TRANSPARENT,
79+
Color.TRANSPARENT
80+
)
81+
)
82+
83+
// Ensure background is transparent
84+
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
85+
86+
setContent {
87+
AirSyncTheme {
88+
ClipboardActionScreen(
89+
hasWindowFocus = _windowFocus.value,
90+
onFinished = { finish() }
91+
)
92+
}
93+
}
94+
}
95+
96+
override fun onWindowFocusChanged(hasFocus: Boolean) {
97+
super.onWindowFocusChanged(hasFocus)
98+
_windowFocus.value = hasFocus
99+
}
100+
}
101+
102+
@Composable
103+
fun ClipboardActionScreen(hasWindowFocus: Boolean, onFinished: () -> Unit) {
104+
val context = androidx.compose.ui.platform.LocalContext.current
105+
val dataStoreManager = remember { DataStoreManager.getInstance(context) }
106+
val connectedDevice by dataStoreManager.getLastConnectedDevice().collectAsState(initial = null)
107+
108+
var uiState by remember { mutableStateOf<ClipboardUiState>(ClipboardUiState.Loading) }
109+
var hasAttemptedSync by remember { mutableStateOf(false) }
110+
111+
ClipboardActionScreenContent(
112+
uiState = uiState,
113+
connectedDevice = connectedDevice,
114+
onFinished = onFinished
115+
)
116+
117+
LaunchedEffect(hasWindowFocus) {
118+
if (hasWindowFocus && !hasAttemptedSync) {
119+
hasAttemptedSync = true
120+
// Small delay to ensure system considers us "interacted" if needed,
121+
// though focus should be enough.
122+
delay(100)
123+
124+
try {
125+
val clipboardText = ClipboardUtil.getClipboardText(context)
126+
127+
if (!clipboardText.isNullOrEmpty()) {
128+
ClipboardSyncManager.syncTextToDesktop(clipboardText)
129+
uiState = ClipboardUiState.Success
130+
delay(1200) // Show success for 1.2s
131+
onFinished()
132+
} else {
133+
uiState = ClipboardUiState.Error("Clipboard empty")
134+
delay(1500)
135+
onFinished()
136+
}
137+
} catch (_: Exception) {
138+
uiState = ClipboardUiState.Error("Failed")
139+
delay(1500)
140+
onFinished()
141+
}
142+
}
143+
}
144+
}
145+
146+
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class)
147+
@Composable
148+
fun ClipboardActionScreenContent(
149+
uiState: ClipboardUiState,
150+
connectedDevice: ConnectedDevice?,
151+
onFinished: () -> Unit
152+
) {
153+
// Transparent background that dismisses on click
154+
Box(
155+
modifier = Modifier
156+
.fillMaxSize()
157+
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.2f))
158+
159+
.navigationBarsPadding()
160+
.clickable(onClick = onFinished),
161+
contentAlignment = Alignment.Center
162+
) {
163+
Surface(
164+
modifier = Modifier.padding(24.dp),
165+
shape = RoundedCornerShape(28.dp),
166+
color = MaterialTheme.colorScheme.surfaceContainer,
167+
tonalElevation = 6.dp,
168+
shadowElevation = 8.dp
169+
) {
170+
Box(
171+
modifier = Modifier
172+
.padding(24.dp),
173+
contentAlignment = Alignment.Center
174+
) {
175+
AnimatedContent(
176+
targetState = uiState,
177+
transitionSpec = { fadeIn().togetherWith(fadeOut()) },
178+
label = "ClipboardStateAnimation"
179+
) { state ->
180+
Column(
181+
horizontalAlignment = Alignment.CenterHorizontally
182+
) {
183+
// Device preview with overlay
184+
Box(
185+
contentAlignment = Alignment.Center,
186+
modifier = Modifier.padding(bottom = 16.dp)
187+
) {
188+
val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice)
189+
Image(
190+
painter = painterResource(id = previewRes),
191+
contentDescription = "Device Preview",
192+
modifier = Modifier.fillMaxWidth(0.9f),
193+
contentScale = ContentScale.Fit
194+
)
195+
196+
Box(
197+
modifier = Modifier
198+
.size(56.dp)
199+
.background(MaterialTheme.colorScheme.surfaceContainerHigh, shape = CircleShape)
200+
) {
201+
// Overlay icon/indicator
202+
when (state) {
203+
is ClipboardUiState.Loading -> {
204+
LoadingIndicator(
205+
modifier = Modifier.size(56.dp),
206+
color = MaterialTheme.colorScheme.primary
207+
)
208+
}
209+
210+
is ClipboardUiState.Success -> {
211+
Icon(
212+
imageVector = Icons.Rounded.CheckCircle,
213+
contentDescription = "Success",
214+
modifier = Modifier.size(56.dp),
215+
tint = MaterialTheme.colorScheme.primary
216+
)
217+
}
218+
219+
is ClipboardUiState.Error -> {
220+
Icon(
221+
imageVector = Icons.Rounded.Error,
222+
contentDescription = "Error",
223+
tint = MaterialTheme.colorScheme.error,
224+
modifier = Modifier.size(56.dp)
225+
)
226+
}
227+
}
228+
}
229+
}
230+
231+
Text(
232+
text = connectedDevice?.name ?: stringResource(R.string.your_mac),
233+
style = MaterialTheme.typography.titleMedium,
234+
color = MaterialTheme.colorScheme.onPrimary,
235+
modifier = Modifier
236+
.background(MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(32.dp))
237+
.padding(horizontal = 16.dp, vertical = 4.dp),
238+
)
239+
240+
Spacer(modifier = Modifier.height(8.dp))
241+
242+
// Status Text
243+
Text(
244+
text = when (state) {
245+
is ClipboardUiState.Loading -> stringResource(R.string.sending)
246+
is ClipboardUiState.Success -> stringResource(R.string.clipboard_sent)
247+
is ClipboardUiState.Error -> stringResource(R.string.failed_to_send_clipboard)
248+
},
249+
style = MaterialTheme.typography.titleSmall,
250+
color = if (state is ClipboardUiState.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface
251+
)
252+
}
253+
}
254+
}
255+
}
256+
}
257+
}
258+
259+
sealed class ClipboardUiState {
260+
data object Loading : ClipboardUiState()
261+
data object Success : ClipboardUiState()
262+
data class Error(val message: String) : ClipboardUiState()
263+
}
264+
265+
@Preview(name = "Loading State", showBackground = true)
266+
@Composable
267+
private fun ClipboardActionScreenPreviewLoading() {
268+
AirSyncTheme {
269+
ClipboardActionScreenContent(uiState = ClipboardUiState.Loading, connectedDevice = null, onFinished = {})
270+
}
271+
}
272+
273+
@Preview(name = "Success State", showBackground = true)
274+
@Composable
275+
private fun ClipboardActionScreenPreviewSuccess() {
276+
AirSyncTheme {
277+
ClipboardActionScreenContent(uiState = ClipboardUiState.Success, connectedDevice = null, onFinished = {})
278+
}
279+
}
280+
281+
@Preview(name = "Error State", showBackground = true)
282+
@Composable
283+
private fun ClipboardActionScreenPreviewError() {
284+
AirSyncTheme {
285+
ClipboardActionScreenContent(uiState = ClipboardUiState.Error("Failed to sync"), connectedDevice = null, onFinished = {})
286+
}
287+
}

app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ fun SettingsView(
101101
)
102102
PermissionsCard(missingPermissionsCount = uiState.missingPermissions.size)
103103
QuickSettingsTipCard(
104-
isQSTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(context)
104+
isQSTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(context, com.sameerasw.airsync.service.AirSyncTileService::class.java)
105+
)
106+
com.sameerasw.airsync.presentation.ui.components.cards.ClipboardTileTipCard(
107+
isQSTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(context, com.sameerasw.airsync.service.ClipboardTileService::class.java)
105108
)
106109
}
107110

0 commit comments

Comments
 (0)