Skip to content

Commit 40cb9b6

Browse files
Add service startup splash (#2462)
- Add a compact startup splash for the accessibility service - Suppress the Pro status HUD on service launch - Preserve trial HUD behavior for non-Pro and trial states 🤖 Auto-generated Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 467b47e commit 40cb9b6

5 files changed

Lines changed: 263 additions & 11 deletions

File tree

app/src/main/java/com/enaboapps/switchify/service/core/SwitchifyAccessibilityService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.enaboapps.switchify.service.techniques.AccessTechnique
2323
import com.enaboapps.switchify.service.trial.ServiceTrialManager
2424
import com.enaboapps.switchify.service.trial.ServiceTrialOverlay
2525
import com.enaboapps.switchify.service.utils.DeviceLockObserver
26+
import com.enaboapps.switchify.service.window.ServiceStartupSplash
2627
import com.enaboapps.switchify.service.window.SwitchifyAccessibilityWindow
2728
import com.enaboapps.switchify.utils.LogEvent
2829
import com.enaboapps.switchify.utils.Logger
@@ -267,6 +268,8 @@ class SwitchifyAccessibilityService : AccessibilityService(), LifecycleOwner,
267268

268269
setup()
269270

271+
ServiceStartupSplash.instance.show()
272+
270273
startTrialOverlayIfNeeded()
271274

272275
Logger.log(LogEvent.ServiceConnected)

app/src/main/java/com/enaboapps/switchify/service/trial/ServiceTrialManager.kt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,6 @@ class ServiceTrialManager(
102102
"reason" to "pro_user"
103103
)
104104
)
105-
ServiceMessageHUD.instance.showMessage(
106-
R.string.pro_unlimited_access_message,
107-
ServiceMessageHUD.MessageType.DISAPPEARING,
108-
severity = MessageSeverity.Success
109-
)
110105
return
111106
}
112107

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package com.enaboapps.switchify.service.window
2+
3+
import android.content.Context
4+
import android.os.Handler
5+
import android.os.Looper
6+
import android.util.Log
7+
import androidx.compose.animation.AnimatedVisibility
8+
import androidx.compose.animation.core.RepeatMode
9+
import androidx.compose.animation.core.animateFloat
10+
import androidx.compose.animation.core.infiniteRepeatable
11+
import androidx.compose.animation.core.rememberInfiniteTransition
12+
import androidx.compose.animation.core.tween
13+
import androidx.compose.animation.fadeIn
14+
import androidx.compose.animation.fadeOut
15+
import androidx.compose.animation.scaleIn
16+
import androidx.compose.animation.scaleOut
17+
import androidx.compose.foundation.BorderStroke
18+
import androidx.compose.foundation.background
19+
import androidx.compose.foundation.isSystemInDarkTheme
20+
import androidx.compose.foundation.layout.Arrangement
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.foundation.layout.Column
23+
import androidx.compose.foundation.layout.Row
24+
import androidx.compose.foundation.layout.Spacer
25+
import androidx.compose.foundation.layout.height
26+
import androidx.compose.foundation.layout.padding
27+
import androidx.compose.foundation.layout.size
28+
import androidx.compose.foundation.layout.width
29+
import androidx.compose.foundation.layout.widthIn
30+
import androidx.compose.foundation.shape.CircleShape
31+
import androidx.compose.material3.Card
32+
import androidx.compose.material3.CardDefaults
33+
import androidx.compose.material3.MaterialTheme
34+
import androidx.compose.material3.Text
35+
import androidx.compose.runtime.Composable
36+
import androidx.compose.runtime.mutableStateOf
37+
import androidx.compose.ui.Alignment
38+
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.draw.alpha
40+
import androidx.compose.ui.draw.scale
41+
import androidx.compose.ui.graphics.Color
42+
import androidx.compose.ui.text.font.FontWeight
43+
import androidx.compose.ui.text.style.TextAlign
44+
import androidx.compose.ui.unit.dp
45+
import com.enaboapps.switchify.R
46+
import com.enaboapps.switchify.service.components.AccessibilityComposeView
47+
import com.enaboapps.switchify.utils.Resources
48+
49+
class ServiceStartupSplash private constructor() {
50+
companion object {
51+
val instance: ServiceStartupSplash by lazy { ServiceStartupSplash() }
52+
private const val TAG = "ServiceStartupSplash"
53+
private const val DISPLAY_MS = 1600L
54+
private const val EXIT_MS = 220L
55+
}
56+
57+
private var applicationCtx: Context? = null
58+
private var composeView: AccessibilityComposeView? = null
59+
private val handler = Handler(Looper.getMainLooper())
60+
private val visibleState = mutableStateOf(false)
61+
62+
fun setup(appCtx: Context) {
63+
applicationCtx = appCtx.applicationContext
64+
}
65+
66+
fun show() {
67+
handler.post {
68+
val context = applicationCtx ?: run {
69+
Log.e(TAG, "ApplicationContext is null. Call setup() first.")
70+
return@post
71+
}
72+
73+
handler.removeCallbacksAndMessages(null)
74+
ensureComposeViewIsCreated(context)
75+
attachIfNeeded()
76+
visibleState.value = true
77+
handler.postDelayed({ hide() }, DISPLAY_MS)
78+
}
79+
}
80+
81+
fun hide() {
82+
handler.post {
83+
if (!visibleState.value && composeView == null) return@post
84+
visibleState.value = false
85+
handler.postDelayed({
86+
if (!visibleState.value) {
87+
composeView?.let { view ->
88+
try {
89+
SwitchifyAccessibilityWindow.instance.removeView(view)
90+
} catch (e: Exception) {
91+
Log.e(TAG, "Failed to remove splash view", e)
92+
}
93+
}
94+
}
95+
}, EXIT_MS)
96+
}
97+
}
98+
99+
fun dispose() {
100+
handler.removeCallbacksAndMessages(null)
101+
composeView?.let { view ->
102+
try {
103+
SwitchifyAccessibilityWindow.instance.removeView(view)
104+
} catch (e: Exception) {
105+
Log.e(TAG, "Failed to dispose splash view", e)
106+
}
107+
}
108+
composeView = null
109+
visibleState.value = false
110+
applicationCtx = null
111+
}
112+
113+
private fun ensureComposeViewIsCreated(context: Context) {
114+
if (composeView == null) {
115+
composeView = AccessibilityComposeView(context) {
116+
ServiceStartupSplashUi(isVisible = visibleState.value)
117+
}
118+
}
119+
}
120+
121+
private fun attachIfNeeded() {
122+
val view = composeView ?: return
123+
if (view.parent == null) {
124+
try {
125+
SwitchifyAccessibilityWindow.instance.addViewToCenter(view)
126+
} catch (e: Exception) {
127+
Log.e(TAG, "Failed to attach splash view", e)
128+
}
129+
}
130+
}
131+
132+
@Composable
133+
private fun ServiceStartupSplashUi(isVisible: Boolean) {
134+
val colors = ServiceStartupSplashColors.current()
135+
AnimatedVisibility(
136+
visible = isVisible,
137+
enter = fadeIn(animationSpec = tween(180)) +
138+
scaleIn(initialScale = 0.92f, animationSpec = tween(220)),
139+
exit = fadeOut(animationSpec = tween(EXIT_MS.toInt())) +
140+
scaleOut(targetScale = 0.96f, animationSpec = tween(EXIT_MS.toInt()))
141+
) {
142+
Card(
143+
modifier = Modifier
144+
.widthIn(max = 360.dp)
145+
.padding(16.dp),
146+
shape = MaterialTheme.shapes.medium,
147+
colors = CardDefaults.cardColors(
148+
containerColor = colors.container,
149+
contentColor = colors.content
150+
),
151+
border = BorderStroke(1.dp, colors.border),
152+
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
153+
) {
154+
Column(
155+
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
156+
horizontalAlignment = Alignment.CenterHorizontally
157+
) {
158+
PulseIndicator()
159+
Spacer(modifier = Modifier.height(14.dp))
160+
Text(
161+
text = Resources.getString(R.string.service_startup_splash_title),
162+
style = MaterialTheme.typography.headlineSmall,
163+
fontWeight = FontWeight.SemiBold,
164+
textAlign = TextAlign.Center,
165+
color = colors.content
166+
)
167+
Spacer(modifier = Modifier.height(4.dp))
168+
Text(
169+
text = Resources.getString(R.string.service_startup_splash_subtitle),
170+
style = MaterialTheme.typography.bodyMedium,
171+
textAlign = TextAlign.Center,
172+
color = colors.secondaryContent
173+
)
174+
}
175+
}
176+
}
177+
}
178+
179+
@Composable
180+
private fun PulseIndicator() {
181+
val colors = ServiceStartupSplashColors.current()
182+
val transition = rememberInfiniteTransition(label = "ServiceStartupSplashPulse")
183+
val pulse = transition.animateFloat(
184+
initialValue = 0.82f,
185+
targetValue = 1.12f,
186+
animationSpec = infiniteRepeatable(
187+
animation = tween(durationMillis = 700),
188+
repeatMode = RepeatMode.Reverse
189+
),
190+
label = "ServiceStartupSplashPulseScale"
191+
)
192+
val alpha = transition.animateFloat(
193+
initialValue = 0.45f,
194+
targetValue = 1f,
195+
animationSpec = infiniteRepeatable(
196+
animation = tween(durationMillis = 700),
197+
repeatMode = RepeatMode.Reverse
198+
),
199+
label = "ServiceStartupSplashPulseAlpha"
200+
)
201+
202+
Row(
203+
horizontalArrangement = Arrangement.spacedBy(8.dp),
204+
verticalAlignment = Alignment.CenterVertically
205+
) {
206+
repeat(3) { index ->
207+
val dotScale = if (index == 1) pulse.value else 0.9f + ((pulse.value - 0.82f) * 0.35f)
208+
Box(
209+
modifier = Modifier
210+
.size(if (index == 1) 12.dp else 9.dp)
211+
.scale(dotScale)
212+
.alpha(if (index == 1) alpha.value else 0.7f)
213+
.background(colors.indicator, CircleShape)
214+
)
215+
if (index == 0) {
216+
Spacer(modifier = Modifier.width(1.dp))
217+
}
218+
}
219+
}
220+
}
221+
222+
private data class ServiceStartupSplashColors(
223+
val container: Color,
224+
val border: Color,
225+
val content: Color,
226+
val secondaryContent: Color,
227+
val indicator: Color
228+
) {
229+
companion object {
230+
@Composable
231+
fun current(): ServiceStartupSplashColors {
232+
return if (isSystemInDarkTheme()) {
233+
ServiceStartupSplashColors(
234+
container = Color(0xFF211F24).copy(alpha = 0.97f),
235+
border = Color(0xFF4F4852).copy(alpha = 0.9f),
236+
content = Color(0xFFF4EFF4),
237+
secondaryContent = Color(0xFFD7D0D8),
238+
indicator = MaterialTheme.colorScheme.primary
239+
)
240+
} else {
241+
ServiceStartupSplashColors(
242+
container = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
243+
border = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.85f),
244+
content = MaterialTheme.colorScheme.onSurface,
245+
secondaryContent = MaterialTheme.colorScheme.onSurfaceVariant,
246+
indicator = MaterialTheme.colorScheme.primary
247+
)
248+
}
249+
}
250+
}
251+
}
252+
}

app/src/main/java/com/enaboapps/switchify/service/window/SwitchifyAccessibilityWindow.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
5959
registerScreenWatcher()
6060
ServiceMessageHUD.instance.setup(context.applicationContext)
6161
MenuHighlightHud.instance.setup(context.applicationContext)
62+
ServiceStartupSplash.instance.setup(context.applicationContext)
6263
} catch (e: Exception) {
6364
Log.e(TAG, "Error in setup: ${e.message}", e)
6465
}
@@ -95,11 +96,13 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
9596
val wake = {
9697
ServiceMessageHUD.instance.setup(context.applicationContext)
9798
MenuHighlightHud.instance.setup(context.applicationContext)
99+
ServiceStartupSplash.instance.setup(context.applicationContext)
98100
show()
99101
}
100102
val sleep = {
101103
ServiceMessageHUD.instance.dispose()
102104
MenuHighlightHud.instance.dispose()
105+
ServiceStartupSplash.instance.dispose()
103106
hide()
104107
}
105108
screenWatcher = ScreenWatcher(onScreenWake = wake, onScreenSleep = sleep)
@@ -142,11 +145,7 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
142145
windowManager?.addView(layout, params)
143146
isVisible = true
144147

145-
// Set up lifecycle owners after the view is added to reduce ANR risk
146-
// on low-end devices during ViewRootImpl initialization
147-
mainHandler.post {
148-
setupLifecycleOwners()
149-
}
148+
setupLifecycleOwners()
150149
}
151150
} catch (e: Exception) {
152151
Log.e(TAG, "Error in show: ${e.message}", e)
@@ -203,6 +202,7 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
203202
fun onServiceDestroy() {
204203
ServiceMessageHUD.instance.dispose()
205204
MenuHighlightHud.instance.dispose()
205+
ServiceStartupSplash.instance.dispose()
206206
MediaPipeBackend.close()
207207
cleanup()
208208
isVisible = false // Ensure the flag is set to false for the next time the window is created
@@ -353,4 +353,4 @@ class SwitchifyAccessibilityWindow private constructor() : LifecycleOwner, Saved
353353
}
354354
}
355355
}
356-
}
356+
}

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@
171171
<string name="pro_unlimited_access_message">Switchify Pro activated. Unlimited access to all features!</string>
172172
<string name="debug_trial_disabled_message">Debug mode: Trials disabled. Unlimited access!</string>
173173
<string name="trial_blocked_device_locked_message">Device locked. New trial start blocked for security.</string>
174+
<string name="service_startup_splash_title">Switchify</string>
175+
<string name="service_startup_splash_subtitle">Service ready</string>
174176
<string name="debug_disable_trials_title">Disable Trials</string>
175177
<string name="debug_disable_trials_summary">Completely disable trial system for unlimited access (debug only)</string>
176178

0 commit comments

Comments
 (0)