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.
- package/CapacitorLottieSplash.podspec +19 -0
- package/Package.swift +30 -0
- package/README.md +120 -0
- package/android/build.gradle +36 -0
- package/android/settings.gradle +7 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashCoordinator.kt +498 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashLaunch.kt +72 -0
- package/android/src/main/java/com/headcount/plugins/lottiesplash/LottieSplashPlugin.kt +121 -0
- package/android/src/main/res/drawable-nodpi/headcount_splash_poster.png +0 -0
- package/dist/esm/definitions.d.ts +65 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/state-machine.d.ts +19 -0
- package/dist/esm/state-machine.js +79 -0
- package/dist/esm/web.d.ts +31 -0
- package/dist/esm/web.js +175 -0
- package/dist/plugin.cjs.js +17305 -0
- package/dist/plugin.js +17308 -0
- package/ios/Sources/LottieSplashPlugin/LottieSplashCoordinator.swift +501 -0
- package/ios/Sources/LottieSplashPlugin/LottieSplashPlugin.swift +71 -0
- package/package.json +78 -0
|
@@ -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
|
+
}
|