capacitor-lottie-splash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ package com.headcount.plugins.lottiesplash
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.content.Context
5
+ import android.graphics.Color
6
+ import android.graphics.drawable.GradientDrawable
7
+ import android.os.Handler
8
+ import android.os.Looper
9
+ import android.os.SystemClock
10
+ import android.view.Gravity
11
+ import android.view.View
12
+ import android.view.ViewGroup
13
+ import android.view.animation.AccelerateDecelerateInterpolator
14
+ import android.view.animation.LinearInterpolator
15
+ import android.widget.Button
16
+ import android.widget.FrameLayout
17
+ import android.widget.LinearLayout
18
+ import android.widget.TextView
19
+ import com.airbnb.lottie.LottieAnimationView
20
+ import com.airbnb.lottie.LottieCompositionFactory
21
+ import androidx.activity.ComponentActivity
22
+ import java.util.UUID
23
+ import kotlin.math.max
24
+ import kotlin.math.roundToInt
25
+
26
+ enum class AndroidSplashPhase { PREPARING, PLAYING, HOLDING, HIDING, HIDDEN, RECOVERY }
27
+ enum class AndroidSplashPlayback { PLAY, HOLD_FINAL_FRAME }
28
+
29
+ /** A small indeterminate rail that reads as loading without covering the finished logo. */
30
+ private class LottieSplashLoadingRail(context: Context, darkTheme: Boolean) : FrameLayout(context) {
31
+ private val segment = View(context)
32
+ private var sweep: ValueAnimator? = null
33
+
34
+ init {
35
+ contentDescription = "Application is still loading"
36
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
37
+ background = GradientDrawable().apply {
38
+ cornerRadius = dp(2).toFloat()
39
+ setColor(if (darkTheme) Color.argb(46, 255, 255, 255) else Color.argb(36, 0, 0, 0))
40
+ }
41
+ segment.background = GradientDrawable().apply {
42
+ cornerRadius = dp(2).toFloat()
43
+ setColor(Color.argb(230, 255, 77, 77))
44
+ }
45
+ clipToOutline = true
46
+ addView(segment, LayoutParams(dp(37), LayoutParams.MATCH_PARENT))
47
+ }
48
+
49
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
50
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
51
+ // The parent is sized before its segment completes layout. Deferring one frame makes
52
+ // the sweep start from the segment's real width rather than silently staying static.
53
+ post { startSweepIfReady() }
54
+ }
55
+
56
+ override fun onDetachedFromWindow() {
57
+ sweep?.cancel()
58
+ sweep = null
59
+ super.onDetachedFromWindow()
60
+ }
61
+
62
+ private fun startSweepIfReady() {
63
+ if (width <= 0 || segment.width <= 0 || sweep != null) return
64
+ sweep = ValueAnimator.ofFloat(-segment.width.toFloat(), width.toFloat()).apply {
65
+ duration = 1_150L
66
+ repeatCount = ValueAnimator.INFINITE
67
+ interpolator = LinearInterpolator()
68
+ addUpdateListener { segment.translationX = it.animatedValue as Float }
69
+ start()
70
+ }
71
+ }
72
+
73
+ private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
74
+ }
75
+
76
+ /** One process-wide coordinator owns the native cover and its presentation generation. */
77
+ object LottieSplashCoordinator {
78
+ // A Capgo bundle from before this plugin existed cannot call show() to adopt this cover.
79
+ // Release it after a short grace period rather than trapping the app during a staged rollout.
80
+ // HeadCount's real cold start can take longer than ten seconds before Angular reaches its
81
+ // first bridge call. Keep a bounded escape hatch for an old over-the-air bundle without
82
+ // incorrectly dismissing a healthy native cover during that startup work.
83
+ private const val WEB_ADOPTION_GRACE_MS = 30_000L
84
+ private const val PROGRESS_DELAY_MS = 1_000L
85
+ private const val RECOVERY_DELAY_MS = 15_000L
86
+ private const val COVER_FADE_OUT_MS = 500L
87
+ private val main = Handler(Looper.getMainLooper())
88
+
89
+ private var cover: FrameLayout? = null
90
+ private var animation: LottieAnimationView? = null
91
+ private var hostActivity: ComponentActivity? = null
92
+ private var hostForeground = false
93
+ private var systemHandoffComplete = true
94
+ private var compositionReady = false
95
+ private var requestedPlayback = AndroidSplashPlayback.PLAY
96
+ private var loadingRail: LottieSplashLoadingRail? = null
97
+ private var recoveryMessage: TextView? = null
98
+ private var recoveryRetry: Button? = null
99
+ private var presentationId: String? = null
100
+ private var phase = AndroidSplashPhase.HIDDEN
101
+ private var playbackSettled = false
102
+ private var appReady = false
103
+ private var darkTheme = true
104
+ private var playbackOutcome = "static-fallback"
105
+ private var playbackReason: String? = null
106
+ private var completion: ((String, String?) -> Unit)? = null
107
+ private var pendingHide: ((Throwable?) -> Unit)? = null
108
+ private var progressTask: Runnable? = null
109
+ private var recoveryTask: Runnable? = null
110
+ private var adoptionTask: Runnable? = null
111
+ private var adoptedByWeb = false
112
+ private var progressRemainingMs = PROGRESS_DELAY_MS
113
+ private var recoveryRemainingMs = RECOVERY_DELAY_MS
114
+ private var waitingClockStartedAtMs = 0L
115
+ private var waitingClockRunning = false
116
+
117
+ var onStateChange: (() -> Unit)? = null
118
+ var onRecoveryRequested: (() -> Unit)? = null
119
+
120
+ /**
121
+ * Called by AndroidX while deciding whether its system-owned launch surface may leave.
122
+ *
123
+ * Ownership is enough here: waiting for `isAttachedToWindow` can deadlock this handoff,
124
+ * because the keep condition runs before the first layout pass that would report that
125
+ * attachment. The activity identity still prevents a cover left by an older host from
126
+ * releasing a new activity's system splash.
127
+ */
128
+ @JvmStatic fun isCoverAttached(activity: ComponentActivity): Boolean =
129
+ hostActivity === activity && cover != null
130
+
131
+ /** Attach an opaque presentation to any host activity. */
132
+ @JvmStatic
133
+ fun attachCover(
134
+ activity: ComponentActivity,
135
+ darkTheme: Boolean = true,
136
+ playback: AndroidSplashPlayback = AndroidSplashPlayback.PLAY,
137
+ dismissWhenUnadopted: Boolean = false
138
+ ) {
139
+ if (cover != null && hostActivity === activity) return
140
+ if (cover != null) discardHost(hostActivity)
141
+ val root = activity.findViewById<ViewGroup>(android.R.id.content) ?: return
142
+ this.darkTheme = darkTheme
143
+ hostActivity = activity
144
+ hostForeground = activity.hasWindowFocus()
145
+ systemHandoffComplete = !dismissWhenUnadopted
146
+ compositionReady = false
147
+ requestedPlayback = playback
148
+ presentationId = UUID.randomUUID().toString()
149
+ phase = AndroidSplashPhase.PREPARING
150
+ playbackSettled = false
151
+ appReady = false
152
+ adoptedByWeb = false
153
+ playbackOutcome = "static-fallback"
154
+ playbackReason = null
155
+ loadingRail = null
156
+ recoveryMessage = null
157
+ recoveryRetry = null
158
+
159
+ val backgroundStart = if (darkTheme) Color.rgb(30, 30, 30) else Color.rgb(240, 240, 240)
160
+ root.setBackgroundColor(backgroundStart)
161
+ activity.window.decorView.setBackgroundColor(backgroundStart)
162
+
163
+ val overlay = FrameLayout(activity).apply {
164
+ layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
165
+ isClickable = true
166
+ isFocusable = true
167
+ contentDescription = "Loading application"
168
+ alpha = 1f
169
+ background = GradientDrawable(
170
+ GradientDrawable.Orientation.LEFT_RIGHT,
171
+ if (darkTheme) intArrayOf(backgroundStart, Color.rgb(37, 37, 37))
172
+ else intArrayOf(Color.rgb(240, 240, 240), Color.rgb(245, 245, 245))
173
+ )
174
+ }
175
+ val lottie = LottieAnimationView(activity).apply {
176
+ layoutParams = FrameLayout.LayoutParams(0, 0, Gravity.CENTER)
177
+ repeatCount = 0
178
+ }
179
+ overlay.addView(lottie)
180
+ root.addView(overlay)
181
+ overlay.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateAnimationLayout(overlay, lottie) }
182
+ overlay.post { updateAnimationLayout(overlay, lottie) }
183
+ cover = overlay
184
+ animation = lottie
185
+ if (dismissWhenUnadopted) scheduleUnadoptedFallback(presentationId!!)
186
+ emit()
187
+
188
+ // Keep the cover unbranded while the composition is prepared. A completed-frame bitmap
189
+ // here made the launch look like a separate, static logo screen before the animation.
190
+ // Lottie parses asynchronously; the first artwork the user sees is therefore the
191
+ // animation's own first visible frame.
192
+ val id = presentationId ?: return
193
+ LottieCompositionFactory.fromAsset(activity, "lottie-splash/animatedLogo.json")
194
+ .addListener { composition ->
195
+ if (presentationId != id || cover !== overlay || hostActivity !== activity) return@addListener
196
+ lottie.setComposition(composition)
197
+ compositionReady = true
198
+ overlay.post {
199
+ startPlaybackWhenVisible(id, overlay, lottie)
200
+ }
201
+ }
202
+ .addFailureListener {
203
+ if (presentationId == id && cover === overlay && hostActivity === activity) {
204
+ settle("static-fallback", "Bundled animation unavailable")
205
+ }
206
+ }
207
+ }
208
+
209
+ fun show(activity: ComponentActivity, darkTheme: Boolean): Pair<String, Boolean>? {
210
+ val id = presentationId
211
+ if (id != null && cover != null) {
212
+ adoptedByWeb = true
213
+ adoptionTask?.let(main::removeCallbacks)
214
+ adoptionTask = null
215
+ return id to true
216
+ }
217
+
218
+ // A splash is also used around long-running in-app transitions. Once the cold-start
219
+ // cover has gone away, create a new presentation instead of rejecting the JS call.
220
+ attachCover(activity, darkTheme, AndroidSplashPlayback.PLAY, dismissWhenUnadopted = false)
221
+ return presentationId?.let { it to false }
222
+ }
223
+
224
+ /** The system-owned launch view has yielded; animation may start if the host is foreground. */
225
+ fun systemSplashDidExit(activity: ComponentActivity) {
226
+ if (hostActivity !== activity) return
227
+ systemHandoffComplete = true
228
+ val id = presentationId ?: return
229
+ val existingCover = cover ?: return
230
+ val existingAnimation = animation ?: return
231
+ startPlaybackWhenVisible(id, existingCover, existingAnimation)
232
+ }
233
+
234
+ fun waitForCompletion(id: String, callback: (String, String?) -> Unit) {
235
+ if (id != presentationId) return callback("static-fallback", "Stale splash presentation")
236
+ if (playbackSettled) callback(playbackOutcome, playbackReason) else completion = callback
237
+ }
238
+
239
+ fun hide(id: String, callback: (Throwable?) -> Unit) {
240
+ if (id != presentationId) return callback(IllegalStateException("Stale splash presentation"))
241
+ appReady = true
242
+ clearWaitingUi()
243
+ if (playbackSettled) dismiss(callback) else {
244
+ pendingHide = callback
245
+ emit()
246
+ }
247
+ }
248
+
249
+ /**
250
+ * The web layer has requested the handoff but its compositor has not acknowledged a frame
251
+ * yet. Stop recovery UI now, while preserving the opaque cover until hide() is called.
252
+ */
253
+ fun prepareForHide(id: String): Throwable? {
254
+ if (id != presentationId) return IllegalStateException("Stale splash presentation")
255
+ appReady = true
256
+ clearWaitingUi()
257
+ emit()
258
+ return null
259
+ }
260
+
261
+ fun state(): Map<String, Any?> = mapOf(
262
+ "presentationId" to presentationId,
263
+ "phase" to phase.name.lowercase(),
264
+ "visible" to (phase != AndroidSplashPhase.HIDDEN),
265
+ "playbackSettled" to playbackSettled,
266
+ "appReady" to appReady,
267
+ "theme" to if (darkTheme) "dark" else "light"
268
+ )
269
+
270
+ /** The host forwards onPause so playback and recovery timing count foreground time only. */
271
+ fun pause(activity: ComponentActivity) {
272
+ if (hostActivity !== activity) return
273
+ hostForeground = false
274
+ if (phase == AndroidSplashPhase.PLAYING) animation?.pauseAnimation()
275
+ if (phase == AndroidSplashPhase.HOLDING) pauseWaitingClock()
276
+ }
277
+
278
+ /** The host forwards onResume so foreground playback and timing can continue. */
279
+ fun resume(activity: ComponentActivity) {
280
+ if (hostActivity !== activity) return
281
+ hostForeground = true
282
+ val id = presentationId
283
+ val existingCover = cover
284
+ val existingAnimation = animation
285
+ if (phase == AndroidSplashPhase.PREPARING && id != null && existingCover != null && existingAnimation != null) {
286
+ startPlaybackWhenVisible(id, existingCover, existingAnimation)
287
+ } else if (phase == AndroidSplashPhase.PLAYING) animation?.resumeAnimation()
288
+ if (phase == AndroidSplashPhase.HOLDING) scheduleWaitingUi(presentationId ?: return)
289
+ }
290
+
291
+ /** Never retain views or callbacks from an activity that is being recreated or destroyed. */
292
+ fun destroy(activity: ComponentActivity) {
293
+ if (hostActivity === activity) discardHost(activity)
294
+ }
295
+
296
+ private fun startPlaybackWhenVisible(id: String, overlay: FrameLayout, lottie: LottieAnimationView) {
297
+ if (id != presentationId || cover !== overlay || animation !== lottie || !compositionReady || !hostForeground || !systemHandoffComplete || phase != AndroidSplashPhase.PREPARING) return
298
+ val composition = lottie.composition ?: return
299
+ phase = AndroidSplashPhase.PLAYING
300
+ emit()
301
+ val startFrame = composition.startFrame
302
+ val endFrame = composition.endFrame
303
+ if (requestedPlayback == AndroidSplashPlayback.HOLD_FINAL_FRAME) {
304
+ lottie.frame = endFrame.toInt()
305
+ settle("completed", null)
306
+ } else if (!ValueAnimator.areAnimatorsEnabled()) {
307
+ lottie.frame = endFrame.toInt()
308
+ settle("reduced-motion", "Android animations disabled")
309
+ } else {
310
+ lottie.setMinAndMaxFrame(startFrame.toInt(), endFrame.toInt())
311
+ lottie.frame = startFrame.toInt()
312
+ lottie.addAnimatorListener(object : android.animation.Animator.AnimatorListener {
313
+ override fun onAnimationStart(animation: android.animation.Animator) = Unit
314
+ override fun onAnimationCancel(animation: android.animation.Animator) {
315
+ if (id == presentationId && phase == AndroidSplashPhase.PLAYING) settle("static-fallback", "Animation cancelled")
316
+ }
317
+ override fun onAnimationRepeat(animation: android.animation.Animator) = Unit
318
+ override fun onAnimationEnd(animation: android.animation.Animator) {
319
+ if (id == presentationId) settle("completed", null)
320
+ }
321
+ })
322
+ lottie.playAnimation()
323
+ }
324
+ }
325
+
326
+ private fun updateAnimationLayout(overlay: FrameLayout, lottie: LottieAnimationView) {
327
+ if (cover !== overlay || animation !== lottie || overlay.width <= 0 || overlay.height <= 0) return
328
+ val availableWidth = minOf(overlay.width.toFloat(), overlay.height * 0.75f)
329
+ val side = (availableWidth * 0.35f).roundToInt().coerceAtLeast(1)
330
+ val params = lottie.layoutParams as FrameLayout.LayoutParams
331
+ if (params.width != side || params.height != side) {
332
+ params.width = side
333
+ params.height = side
334
+ params.gravity = Gravity.CENTER
335
+ lottie.layoutParams = params
336
+ }
337
+ }
338
+
339
+ private fun dp(view: View, value: Int): Int = (value * view.resources.displayMetrics.density).roundToInt()
340
+
341
+ private fun settle(outcome: String, reason: String?) {
342
+ if (playbackSettled || presentationId == null) return
343
+ playbackSettled = true
344
+ playbackOutcome = outcome
345
+ playbackReason = reason
346
+ phase = if (pendingHide != null) AndroidSplashPhase.HIDING else AndroidSplashPhase.HOLDING
347
+ completion?.invoke(outcome, reason)
348
+ completion = null
349
+ if (pendingHide != null) dismiss(pendingHide!!) else if (!appReady) resetWaitingUi(presentationId!!)
350
+ pendingHide = null
351
+ emit()
352
+ }
353
+
354
+ private fun resetWaitingUi(id: String) {
355
+ cancelWaitingTasks()
356
+ progressRemainingMs = PROGRESS_DELAY_MS
357
+ recoveryRemainingMs = RECOVERY_DELAY_MS
358
+ scheduleWaitingUi(id)
359
+ }
360
+
361
+ private fun scheduleUnadoptedFallback(id: String) {
362
+ adoptionTask?.let(main::removeCallbacks)
363
+ adoptionTask = Runnable {
364
+ if (presentationId == id && !adoptedByWeb) dismiss { }
365
+ }
366
+ main.postDelayed(adoptionTask!!, WEB_ADOPTION_GRACE_MS)
367
+ }
368
+
369
+ private fun scheduleWaitingUi(id: String) {
370
+ if (waitingClockRunning || presentationId != id || appReady || phase != AndroidSplashPhase.HOLDING) return
371
+ waitingClockRunning = true
372
+ waitingClockStartedAtMs = SystemClock.elapsedRealtime()
373
+ progressTask = Runnable { showLoadingRail(id) }
374
+ recoveryTask = Runnable { showRecovery(id) }
375
+ if (loadingRail == null) main.postDelayed(progressTask!!, progressRemainingMs)
376
+ main.postDelayed(recoveryTask!!, recoveryRemainingMs)
377
+ }
378
+
379
+ private fun pauseWaitingClock() {
380
+ if (!waitingClockRunning) return
381
+ val elapsed = SystemClock.elapsedRealtime() - waitingClockStartedAtMs
382
+ progressRemainingMs = max(0, progressRemainingMs - elapsed)
383
+ recoveryRemainingMs = max(0, recoveryRemainingMs - elapsed)
384
+ cancelWaitingTasks()
385
+ }
386
+
387
+ private fun cancelWaitingTasks() {
388
+ progressTask?.let(main::removeCallbacks)
389
+ recoveryTask?.let(main::removeCallbacks)
390
+ progressTask = null
391
+ recoveryTask = null
392
+ waitingClockRunning = false
393
+ }
394
+
395
+ private fun showLoadingRail(id: String) {
396
+ progressRemainingMs = 0
397
+ if (presentationId != id || appReady || phase != AndroidSplashPhase.HOLDING || loadingRail != null) return
398
+ val context = cover?.context ?: return
399
+ loadingRail = LottieSplashLoadingRail(context, darkTheme).also { rail ->
400
+ // Keep it below the composition's centered artwork, never layered over the tally.
401
+ cover?.addView(rail, FrameLayout.LayoutParams(dp(rail, 104), dp(rail, 3), Gravity.CENTER).apply {
402
+ topMargin = dp(rail, 100)
403
+ })
404
+ }
405
+ }
406
+
407
+ private fun showRecovery(id: String) {
408
+ recoveryRemainingMs = 0
409
+ if (presentationId != id || appReady || phase != AndroidSplashPhase.HOLDING) return
410
+ cancelWaitingTasks()
411
+ phase = AndroidSplashPhase.RECOVERY
412
+ loadingRail?.visibility = View.GONE
413
+ val context = cover?.context ?: return
414
+ val textColor = if (darkTheme) Color.WHITE else Color.rgb(30, 30, 30)
415
+ val message = TextView(context).apply { text = "Still starting…"; textSize = 16f; setTextColor(textColor); gravity = Gravity.CENTER }
416
+ val retry = Button(context).apply { text = "Try again"; setOnClickListener { onRecoveryRequested?.invoke() } }
417
+ val recoveryGroup = LinearLayout(context).apply {
418
+ orientation = LinearLayout.VERTICAL
419
+ gravity = Gravity.CENTER_HORIZONTAL
420
+ addView(message)
421
+ addView(retry)
422
+ }
423
+ recoveryMessage = message
424
+ recoveryRetry = retry
425
+ cover?.addView(recoveryGroup, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM).apply { bottomMargin = dp(recoveryGroup, 48) })
426
+ recoveryGroup.announceForAccessibility("Still starting. Try again is available.")
427
+ emit()
428
+ }
429
+
430
+ private fun clearWaitingUi() {
431
+ cancelWaitingTasks()
432
+ loadingRail?.let { cover?.removeView(it) }
433
+ recoveryMessage?.parent?.let { parent -> (parent as? ViewGroup)?.let { cover?.removeView(it) } }
434
+ loadingRail = null
435
+ recoveryMessage = null
436
+ recoveryRetry = null
437
+ }
438
+
439
+ private fun dismiss(callback: (Throwable?) -> Unit) {
440
+ val existingCover = cover ?: return callback(null)
441
+ phase = AndroidSplashPhase.HIDING
442
+ clearWaitingUi()
443
+ adoptionTask?.let(main::removeCallbacks)
444
+ adoptionTask = null
445
+ // Stop the renderer before animating the parent away. In particular, do not allow the
446
+ // Lottie canvas to keep a hardware buffer alive above the WebView after removal.
447
+ animation?.cancelAnimation()
448
+ animation?.clearAnimation()
449
+ existingCover.animate()
450
+ .alpha(0f)
451
+ .setDuration(if (ValueAnimator.areAnimatorsEnabled()) COVER_FADE_OUT_MS else 0)
452
+ .setInterpolator(AccelerateDecelerateInterpolator())
453
+ .withEndAction {
454
+ existingCover.clearAnimation()
455
+ existingCover.setLayerType(View.LAYER_TYPE_NONE, null)
456
+ val parent = existingCover.parent as? ViewGroup
457
+ parent?.removeView(existingCover)
458
+ // Removing a translucent native view does not always invalidate an underlying
459
+ // WebView immediately on Android. Request a fresh root frame before resolving hide.
460
+ parent?.requestLayout()
461
+ parent?.invalidate()
462
+ parent?.post { parent.invalidate() }
463
+ cover = null
464
+ animation = null
465
+ hostActivity = null
466
+ loadingRail = null
467
+ recoveryMessage = null
468
+ recoveryRetry = null
469
+ presentationId = null
470
+ phase = AndroidSplashPhase.HIDDEN
471
+ compositionReady = false
472
+ emit()
473
+ callback(null)
474
+ }.start()
475
+ emit()
476
+ }
477
+
478
+ private fun discardHost(activity: ComponentActivity?) {
479
+ if (activity == null) return
480
+ adoptionTask?.let(main::removeCallbacks)
481
+ adoptionTask = null
482
+ clearWaitingUi()
483
+ animation?.cancelAnimation()
484
+ animation?.clearAnimation()
485
+ cover?.let { existing -> (existing.parent as? ViewGroup)?.removeView(existing) }
486
+ cover = null
487
+ animation = null
488
+ hostActivity = null
489
+ presentationId = null
490
+ phase = AndroidSplashPhase.HIDDEN
491
+ compositionReady = false
492
+ pendingHide = null
493
+ completion = null
494
+ emit()
495
+ }
496
+
497
+ private fun emit() { onStateChange?.invoke() }
498
+ }
@@ -0,0 +1,72 @@
1
+ package com.headcount.plugins.lottiesplash
2
+
3
+ import android.content.Context
4
+ import android.content.res.Configuration
5
+ import androidx.activity.ComponentActivity
6
+ import androidx.core.splashscreen.SplashScreen
7
+ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
8
+
9
+ /** A host-defined Capacitor Preferences value available before the WebView starts. */
10
+ class LottieSplashThemePreference @JvmOverloads constructor(
11
+ val key: String,
12
+ val darkValue: String,
13
+ val lightValue: String,
14
+ val storageName: String = "CapacitorStorage"
15
+ )
16
+
17
+ /** Host-only integration for one native splash presentation in the Capacitor activity. */
18
+ object LottieSplashLaunch {
19
+ /**
20
+ * Call before super.onCreate() in the host activity. Android owns this first surface;
21
+ * it is retained only until the plugin's native cover has been added to the activity.
22
+ */
23
+ @JvmStatic fun installSystemSplashBeforeCreate(activity: ComponentActivity): SplashScreen =
24
+ activity.installSplashScreen().also { systemSplash ->
25
+ systemSplash.setKeepOnScreenCondition { !LottieSplashCoordinator.isCoverAttached(activity) }
26
+ systemSplash.setOnExitAnimationListener { provider ->
27
+ // The native cover is already fully opaque. Removing this system-owned surface
28
+ // immediately prevents a translucent crossfade from exposing a mismatched root.
29
+ provider.remove()
30
+ LottieSplashCoordinator.systemSplashDidExit(activity)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Attach the app-owned native cover before Capacitor constructs its bridge. The cover stays
36
+ * in this activity until the web layer has confirmed that its first route is ready to reveal.
37
+ */
38
+ @JvmStatic
39
+ fun attachNativeCover(activity: ComponentActivity, darkTheme: Boolean) {
40
+ LottieSplashCoordinator.attachCover(
41
+ activity = activity,
42
+ darkTheme = darkTheme,
43
+ playback = AndroidSplashPlayback.PLAY,
44
+ // An older over-the-air bundle cannot adopt a newly-installed native cover. Keep
45
+ // the bounded release path enabled so a staged rollout cannot trap the app here.
46
+ dismissWhenUnadopted = true
47
+ )
48
+ }
49
+
50
+ @JvmStatic
51
+ fun isSystemDark(activity: Context): Boolean =
52
+ (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
53
+
54
+ @JvmStatic
55
+ fun resolveTheme(context: Context, preference: LottieSplashThemePreference): Boolean? {
56
+ val preferences = context.getSharedPreferences(preference.storageName, Context.MODE_PRIVATE)
57
+ return when (preferences.getString(preference.key, null)) {
58
+ preference.darkValue -> true
59
+ preference.lightValue -> false
60
+ else -> null
61
+ }
62
+ }
63
+
64
+ /** Forward the host activity lifecycle so recovery timing counts foreground time only. */
65
+ @JvmStatic fun onHostPaused(activity: ComponentActivity) = LottieSplashCoordinator.pause(activity)
66
+
67
+ /** Forward the host activity lifecycle so a paused animation can resume. */
68
+ @JvmStatic fun onHostResumed(activity: ComponentActivity) = LottieSplashCoordinator.resume(activity)
69
+
70
+ /** Release views and asynchronous work tied to an activity that is being destroyed. */
71
+ @JvmStatic fun onHostDestroyed(activity: ComponentActivity) = LottieSplashCoordinator.destroy(activity)
72
+ }
@@ -0,0 +1,121 @@
1
+ package com.headcount.plugins.lottiesplash
2
+
3
+ import android.app.UiModeManager
4
+ import android.os.Build
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.os.SystemClock
8
+ import android.webkit.WebView
9
+ import java.util.concurrent.atomic.AtomicBoolean
10
+ import com.getcapacitor.JSObject
11
+ import com.getcapacitor.Plugin
12
+ import com.getcapacitor.PluginCall
13
+ import com.getcapacitor.PluginMethod
14
+ import com.getcapacitor.annotation.CapacitorPlugin
15
+
16
+ @CapacitorPlugin(name = "LottieSplash")
17
+ class LottieSplashPlugin : Plugin() {
18
+ private companion object {
19
+ const val VISUAL_STATE_TIMEOUT_MS = 1_500L
20
+ }
21
+ override fun load() {
22
+ LottieSplashCoordinator.onStateChange = { notifyListeners("stateChange", stateObject()) }
23
+ LottieSplashCoordinator.onRecoveryRequested = { bridge?.webView?.reload() }
24
+ }
25
+
26
+ @PluginMethod
27
+ fun show(call: PluginCall) {
28
+ activity.runOnUiThread {
29
+ val darkTheme = call.getBoolean("darkMode") ?: LottieSplashLaunch.isSystemDark(activity)
30
+ val presentation = LottieSplashCoordinator.show(activity, darkTheme)
31
+ ?: return@runOnUiThread call.reject("Native splash has not been installed by the host activity")
32
+ val (id, adopted) = presentation
33
+ call.resolve(JSObject().put("presentationId", id).put("renderer", "android").put("adopted", adopted))
34
+ }
35
+ }
36
+
37
+ @PluginMethod
38
+ fun setLaunchTheme(call: PluginCall) {
39
+ val darkTheme = call.getBoolean("darkMode") ?: return call.reject("darkMode is required")
40
+ activity.runOnUiThread {
41
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
42
+ val manager = activity.getSystemService(UiModeManager::class.java)
43
+ manager.setApplicationNightMode(
44
+ if (darkTheme) UiModeManager.MODE_NIGHT_YES else UiModeManager.MODE_NIGHT_NO
45
+ )
46
+ }
47
+ call.resolve()
48
+ }
49
+ }
50
+
51
+ @PluginMethod
52
+ fun waitForCompletion(call: PluginCall) {
53
+ val id = call.getString("presentationId") ?: return call.reject("presentationId is required")
54
+ LottieSplashCoordinator.waitForCompletion(id) { outcome, reason ->
55
+ call.resolve(JSObject().put("presentationId", id).put("outcome", outcome).put("reason", reason))
56
+ }
57
+ }
58
+
59
+ @PluginMethod
60
+ fun hide(call: PluginCall) {
61
+ val id = call.getString("presentationId") ?: return call.reject("presentationId is required")
62
+ activity.runOnUiThread {
63
+ LottieSplashCoordinator.prepareForHide(id)?.let { error ->
64
+ call.reject(error.message)
65
+ return@runOnUiThread
66
+ }
67
+ val webView = bridge?.webView
68
+ if (webView == null) {
69
+ LottieSplashCoordinator.hide(id) { error -> resolveHide(call, error) }
70
+ return@runOnUiThread
71
+ }
72
+
73
+ // JavaScript's requestAnimationFrame only proves that a DOM frame was scheduled.
74
+ // Keep the native cover until WebView confirms that the sign-in DOM state has been
75
+ // composited; otherwise a busy cold start can reveal an empty white WebView.
76
+ val finished = AtomicBoolean(false)
77
+ val main = Handler(Looper.getMainLooper())
78
+ lateinit var fallback: Runnable
79
+ val finishHandoff = {
80
+ if (finished.compareAndSet(false, true)) {
81
+ main.removeCallbacks(fallback)
82
+ activity.runOnUiThread {
83
+ LottieSplashCoordinator.hide(id) { error -> resolveHide(call, error) }
84
+ }
85
+ }
86
+ }
87
+ fallback = Runnable { finishHandoff() }
88
+ try {
89
+ webView.postVisualStateCallback(SystemClock.uptimeMillis(), object : WebView.VisualStateCallback() {
90
+ override fun onComplete(requestId: Long) = finishHandoff()
91
+ })
92
+ // A stalled GPU/compositor must not leave an app-ready screen in recovery.
93
+ main.postDelayed(fallback, VISUAL_STATE_TIMEOUT_MS)
94
+ } catch (_: Throwable) {
95
+ finishHandoff()
96
+ }
97
+ }
98
+ }
99
+
100
+ private fun resolveHide(call: PluginCall, error: Throwable?) {
101
+ if (error != null) {
102
+ call.reject(error.message)
103
+ return
104
+ }
105
+ // Ensure the WebView presents a fresh buffer after the native cover has been removed.
106
+ // This prevents a stale native composition region from obscuring sign-in controls.
107
+ bridge?.webView?.post {
108
+ val webView = bridge?.webView ?: return@post
109
+ webView.requestLayout()
110
+ webView.invalidate()
111
+ call.resolve()
112
+ } ?: call.resolve()
113
+ }
114
+
115
+ @PluginMethod fun getState(call: PluginCall) = call.resolve(stateObject())
116
+ @PluginMethod fun getCapabilities(call: PluginCall) = call.resolve(JSObject().put("apiVersion", 1).put("pluginVersion", "0.1.0").put("coldStartInstalled", LottieSplashCoordinator.state()["presentationId"] != null))
117
+
118
+ private fun stateObject(): JSObject = JSObject().apply {
119
+ LottieSplashCoordinator.state().forEach { (key, value) -> put(key, value) }
120
+ }
121
+ }