catalyst-core 1.0.2-canary.1 → 1.0.2

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.
Files changed (90) hide show
  1. package/bin/catalyst.js +105 -1
  2. package/changelog.md +34 -0
  3. package/dist/cli/README.md +119 -0
  4. package/dist/cli/appConfig.js +51 -0
  5. package/dist/cli/buildErrors.js +170 -0
  6. package/dist/cli/buildWarnings.js +28 -0
  7. package/dist/cli/devOutput.js +524 -0
  8. package/dist/cli/diagnostic.js +388 -0
  9. package/dist/cli/hints.js +69 -0
  10. package/dist/cli/package.json +3 -0
  11. package/dist/cli/spinner.js +141 -0
  12. package/dist/cli/theme.js +155 -0
  13. package/dist/errors/index.js +7 -3
  14. package/dist/logger.js +61 -6
  15. package/dist/native/androidProject/app/build.gradle.kts +2 -1
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
  20. package/dist/native/androidSetup.js +46 -57
  21. package/dist/native/buildAndroid/assets.js +22 -13
  22. package/dist/native/buildAndroid/build.js +98 -26
  23. package/dist/native/buildAndroid/config.js +43 -12
  24. package/dist/native/buildAndroid/index.js +49 -191
  25. package/dist/native/buildAppAndroid.js +19 -8
  26. package/dist/native/buildAppIos.js +35 -16
  27. package/dist/native/buildIos/assets.js +1 -6
  28. package/dist/native/buildIos/build.js +66 -95
  29. package/dist/native/buildIos/config.js +26 -8
  30. package/dist/native/buildIos/index.js +143 -64
  31. package/dist/native/buildIos/plugins.js +12 -15
  32. package/dist/native/internalPluginUtils.js +0 -1
  33. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
  34. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
  35. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
  36. package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
  37. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
  38. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
  39. package/dist/native/pluginComposerAndroid.js +2 -3
  40. package/dist/native/renameAndroidProject.js +42 -20
  41. package/dist/native/setupEmulatorIos.js +28 -49
  42. package/dist/native/setupServer.js +12 -15
  43. package/dist/native/terminalProgress.js +186 -147
  44. package/dist/native/utils.js +340 -54
  45. package/dist/otel.js +15 -9
  46. package/dist/scripts/build.js +313 -42
  47. package/dist/scripts/generateOfflineManifest.js +11 -1
  48. package/dist/scripts/loadEnvironmentVariables.js +24 -2
  49. package/dist/scripts/preServerInit.js +32 -10
  50. package/dist/scripts/preflight.js +35 -11
  51. package/dist/scripts/scriptUtils.js +34 -0
  52. package/dist/scripts/serve.js +64 -15
  53. package/dist/scripts/start.js +58 -21
  54. package/dist/server/expressServer.js +167 -32
  55. package/dist/server/renderer/handler.jsx +87 -11
  56. package/dist/server/utils/validator.js +14 -11
  57. package/dist/vite/manifest-categorization-plugin.js +7 -5
  58. package/dist/vite/resolveDevServerConfig.js +9 -2
  59. package/dist/vite/vite.config.client.js +18 -0
  60. package/dist/vite/vite.config.js +73 -13
  61. package/dist/vite/vite.config.server.js +21 -0
  62. package/dist/web-router/components/MetaTag.jsx +15 -2
  63. package/dist/web-router/components/MetaTag.test.jsx +71 -2
  64. package/dist/webmcp/WebMcpProvider.jsx +3 -2
  65. package/dist/webmcp/useTool.js +4 -1
  66. package/mcp_v2/conversion-tasks.json +6 -5
  67. package/mcp_v2/knowledge-base.json +124 -7
  68. package/mcp_v2/test/config.test.ts +59 -0
  69. package/mcp_v2/tools/build.js +6 -6
  70. package/mcp_v2/tools/config.js +35 -18
  71. package/mcp_v2/tools/conversion.js +7 -13
  72. package/package.json +5 -5
  73. package/test/cli/buildErrors.test.ts +173 -0
  74. package/test/cli/buildWarnings.test.cjs +62 -0
  75. package/test/cli/devOutput.test.ts +549 -0
  76. package/test/cli/diagnostic.test.ts +434 -0
  77. package/test/cli/output.test.cjs +90 -0
  78. package/test/cli/spinner.test.ts +177 -0
  79. package/test/cli/theme.test.ts +163 -0
  80. package/test/native/redactSecrets.test.ts +72 -0
  81. package/test/native/utils.test.ts +532 -0
  82. package/test/scripts/build.test.ts +275 -0
  83. package/test/scripts/serve.test.ts +102 -0
  84. package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
  85. package/test/server/handler.test.ts +17 -0
  86. package/test/server/logger.test.ts +185 -0
  87. package/test/server/otel.test.ts +556 -0
  88. package/test/server/validator.test.ts +19 -8
  89. package/dist/scripts/checkVersion.js +0 -3
  90. package/dist/scripts/cleanCache.js +0 -30
@@ -0,0 +1,155 @@
1
+ "use strict"
2
+
3
+ const os = require("os")
4
+ const path = require("path")
5
+ const pc = require("ansis")
6
+
7
+ /**
8
+ * The one visual language shared by every Catalyst command.
9
+ *
10
+ * Rules, so anything added later still looks like it belongs:
11
+ * - One accent (cyan) for identity and links. Green, red and yellow carry
12
+ * state only, never decoration.
13
+ * - Single-width glyphs, never emoji. Emoji render double-width in some
14
+ * terminals, which breaks every column that follows them.
15
+ * - Dim is structure: rules, labels and paths recede so the one thing that
16
+ * matters on a line is the bright one.
17
+ * - Every line starts at the same gutter, so output reads as a column.
18
+ *
19
+ * Authored as CommonJS because src/native/ ships as CJS (see
20
+ * src/native/package.json) while src/scripts/ is ESM -- CJS is the only form
21
+ * both halves can consume.
22
+ */
23
+
24
+ // Colour codes sit in front of text, so anything that matches against output
25
+ // must strip them first. Defined once here rather than per-module.
26
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g")
27
+
28
+ const GUTTER = " "
29
+
30
+ const glyph = {
31
+ pending: "○",
32
+ running: "◐",
33
+ done: "✓",
34
+ fail: "✗",
35
+ warn: "▲",
36
+ info: "•",
37
+ arrow: "›",
38
+ branch: "├",
39
+ last: "└",
40
+ pipe: "│",
41
+ rule: "─",
42
+ }
43
+
44
+ // Frames for the running indicator. Braille spinners look busier than they
45
+ // read; these four are calm and legible at any width.
46
+ const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"]
47
+
48
+ const t = {
49
+ accent: pc.cyan,
50
+ dim: pc.gray,
51
+ ok: pc.green,
52
+ bad: pc.red,
53
+ warn: pc.yellow,
54
+ bold: pc.bold,
55
+ }
56
+
57
+ /**
58
+ * True when we may draw in place: a real terminal, not a pipe, not CI.
59
+ * Vite's gate, so the web and native commands agree on what "interactive"
60
+ * means. ansis independently handles NO_COLOR/FORCE_COLOR/TERM=dumb.
61
+ */
62
+ function isInteractive(stream = process.stdout) {
63
+ return Boolean(stream.isTTY && !process.env.CI)
64
+ }
65
+
66
+ /**
67
+ * True when the user asked for more detail: `--verbose`/`--debug` on this
68
+ * process, or CATALYST_OUTPUT_MODE forwarded by a parent that saw them. The
69
+ * default view keeps only what someone acts on; the rest is here.
70
+ */
71
+ function isVerbose(argv = process.argv, env = process.env) {
72
+ if (argv.includes("--verbose") || argv.includes("--debug")) return true
73
+ return env.CATALYST_OUTPUT_MODE === "verbose" || env.CATALYST_OUTPUT_MODE === "debug"
74
+ }
75
+
76
+ let cachedVersion
77
+ /** catalyst-core's own version: ../../package.json from both src/cli and dist/cli. */
78
+ function version() {
79
+ if (cachedVersion === undefined) {
80
+ try {
81
+ cachedVersion = require("../../package.json").version || null
82
+ } catch (error) {
83
+ cachedVersion = null
84
+ }
85
+ }
86
+ return cachedVersion
87
+ }
88
+
89
+ /** The titled rule every command opens with: `catalyst build v1.0.1 › production`. */
90
+ function header(title, subtitle) {
91
+ const v = version()
92
+ const line =
93
+ `${t.bold(t.accent(title))}` +
94
+ `${v ? t.dim(` v${v}`) : ""}` +
95
+ `${subtitle ? t.dim(` ${glyph.arrow} ${subtitle}`) : ""}`
96
+ return `\n${GUTTER}${line}\n${GUTTER}${t.dim(glyph.rule.repeat(46))}\n`
97
+ }
98
+
99
+ /** A dim-key / bright-value row, values column-aligned. */
100
+ function row(key, value, width = 9) {
101
+ return `${GUTTER}${t.dim(key.padEnd(width))}${value}`
102
+ }
103
+
104
+ function duration(ms) {
105
+ if (ms < 1000) return `${ms}ms`
106
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
107
+ return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
108
+ }
109
+
110
+ /**
111
+ * A path as a person would type it: relative to the app when inside it,
112
+ * `~/...` when under home, absolute otherwise. Absolute paths into DerivedData
113
+ * or node_modules pushed the useful tail off the edge of the terminal.
114
+ */
115
+ function displayPath(file, root = process.cwd()) {
116
+ if (!file) return file
117
+ const relative = path.relative(root, file)
118
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return relative
119
+ const home = os.homedir()
120
+ return file.startsWith(home) ? `~${file.slice(home.length)}` : file
121
+ }
122
+
123
+ function bytes(n) {
124
+ if (n < 1024) return `${n} B`
125
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`
126
+ return `${(n / 1024 / 1024).toFixed(2)} MB`
127
+ }
128
+
129
+ // Below this a timing is noise: "0ms" next to a step says nothing a reader
130
+ // can use, and a column of them hides the one step that was actually slow.
131
+ const MIN_SHOWN_MS = 100
132
+
133
+ /** A completed step: `✓ Label 1.2s` */
134
+ function step(label, ms, state = "done") {
135
+ const mark =
136
+ state === "fail" ? t.bad(glyph.fail) : state === "warn" ? t.warn(glyph.warn) : t.ok(glyph.done)
137
+ return `${GUTTER}${mark} ${label}${ms == null || ms < MIN_SHOWN_MS ? "" : ` ${t.dim(duration(ms))}`}`
138
+ }
139
+
140
+ module.exports = {
141
+ ANSI,
142
+ GUTTER,
143
+ glyph,
144
+ SPINNER_FRAMES,
145
+ t,
146
+ isInteractive,
147
+ isVerbose,
148
+ version,
149
+ header,
150
+ row,
151
+ duration,
152
+ bytes,
153
+ displayPath,
154
+ step,
155
+ }
@@ -138,7 +138,11 @@ const BOX_WIDTH = 70
138
138
  function box(bodyLines) {
139
139
  const top = pc.red(`┌${"─".repeat(BOX_WIDTH)}`)
140
140
  const bottom = pc.red(`└${"─".repeat(BOX_WIDTH)}`)
141
- const body = bodyLines.map((line) => (line === "" ? pc.red("│") : `${pc.red("│")} ${line}`))
141
+ // A multi-line entry (details listing several values) keeps the border on
142
+ // every line instead of spilling out of the box.
143
+ const body = bodyLines
144
+ .flatMap((line) => String(line).split("\n"))
145
+ .map((line) => (line === "" ? pc.red("│") : `${pc.red("│")} ${line}`))
142
146
  return [top, ...body, bottom].join("\n")
143
147
  }
144
148
 
@@ -163,7 +167,7 @@ function formatCauseChain(cause) {
163
167
  */
164
168
  function formatVerbose(err) {
165
169
  const lines = [
166
- pc.bold(`❌ ${err.message}`),
170
+ pc.bold(err.message),
167
171
  `Code: ${err.code}`,
168
172
  `Category: ${err.category || "UNKNOWN"}`,
169
173
  `Time: ${new Date().toISOString()}`,
@@ -193,7 +197,7 @@ function formatVerbose(err) {
193
197
  */
194
198
  function formatDebug(err, env) {
195
199
  const lines = [
196
- pc.bold(`❌ ${err.message}`),
200
+ pc.bold(err.message),
197
201
  `Code: ${err.code}`,
198
202
  `Category: ${err.category || "UNKNOWN"}`,
199
203
  `Time: ${new Date().toISOString()}`,
package/dist/logger.js CHANGED
@@ -13,6 +13,55 @@ const { createLogger, format, transports } = winston
13
13
  * @returns loggerInstance
14
14
  *
15
15
  */
16
+ /**
17
+ * Render a log argument as text.
18
+ *
19
+ * JSON.stringify was wrong in two ways that mattered: an Error serializes to
20
+ * "{}" (message and stack are non-enumerable), so error logs said nothing at
21
+ * all, and a circular reference made the logger itself throw -- turning a
22
+ * logged problem into a crash.
23
+ */
24
+ function formatLogMessage(msg) {
25
+ if (typeof msg === "string") return msg
26
+ if (msg instanceof Error) return msg.stack || `${msg.name}: ${msg.message}`
27
+
28
+ try {
29
+ const seen = new WeakSet()
30
+ const text = JSON.stringify(msg, (key, value) => {
31
+ if (value instanceof Error) return value.stack || `${value.name}: ${value.message}`
32
+ if (typeof value === "bigint") return value.toString()
33
+ if (typeof value === "object" && value !== null) {
34
+ if (seen.has(value)) return "[Circular]"
35
+ seen.add(value)
36
+ }
37
+ return value
38
+ })
39
+ // stringify returns undefined for functions and symbols.
40
+ return text === undefined ? String(msg) : text
41
+ } catch (error) {
42
+ return String(msg)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * What winston receives for a log argument.
48
+ *
49
+ * A plain object goes in as an object, so file logs keep its fields
50
+ * queryable rather than nesting it as one escaped string under "message".
51
+ * It is a clone built from the console text -- cycle-safe, Errors as their
52
+ * stack -- which also keeps winston from writing level and timestamp onto the
53
+ * caller's own object. Strings, Errors and anything else go in as text.
54
+ */
55
+ function toLogRecord(msg, text) {
56
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg) || msg instanceof Error) return text
57
+ try {
58
+ const record = JSON.parse(text)
59
+ return record !== null && typeof record === "object" && !Array.isArray(record) ? record : text
60
+ } catch (error) {
61
+ return text
62
+ }
63
+ }
64
+
16
65
  const configureLogger = (config = {}) => {
17
66
  const { enableDebugLogs = true, enableFileLogging = true, enableConsoleLogging = true } = config
18
67
 
@@ -55,22 +104,28 @@ const configureLogger = (config = {}) => {
55
104
  errorLogger.add(fileTransport("error"))
56
105
  }
57
106
 
107
+ // Winston serializes with format.json(), which renders an Error as {} for
108
+ // the same reason JSON.stringify does, so it gets the formatted text for
109
+ // those -- and a safe structured copy of a plain object (toLogRecord).
58
110
  const Logger = {
59
111
  debug: () => {},
60
112
  error: (msg) => {
61
- console.log(pc.red(pc.bold("ERROR: " + JSON.stringify(msg))))
62
- errorLogger.error(msg)
113
+ const text = formatLogMessage(msg)
114
+ console.log(pc.red(pc.bold("ERROR: " + text)))
115
+ errorLogger.error(toLogRecord(msg, text))
63
116
  },
64
117
  info: (msg) => {
65
- console.log(pc.green(pc.bold("INFO: " + JSON.stringify(msg))))
66
- infoLogger.info(msg)
118
+ const text = formatLogMessage(msg)
119
+ console.log(pc.green(pc.bold("INFO: " + text)))
120
+ infoLogger.info(toLogRecord(msg, text))
67
121
  },
68
122
  }
69
123
 
70
124
  if (enableDebugLogs && JSON.parse(enableDebugLogs)) {
71
125
  Logger.debug = (msg) => {
72
- console.log(pc.yellow(pc.bold("DEBUG: " + JSON.stringify(msg))))
73
- debugLogger.debug(msg)
126
+ const text = formatLogMessage(msg)
127
+ console.log(pc.yellow(pc.bold("DEBUG: " + text)))
128
+ debugLogger.debug(toLogRecord(msg, text))
74
129
  }
75
130
  }
76
131
 
@@ -379,7 +379,8 @@ tasks.register<JacocoReport>("jacocoTestReport") {
379
379
  // so a plain "$*" is safe here.
380
380
  "**/CustomWebView.class", "**/CustomWebView\$*.class", "**/CustomWebViewKt.class",
381
381
  "**/MainActivity.class", "**/MainActivity\$*.class",
382
- "**/SplashActivity.class", "**/SplashActivity\$*.class",
382
+ "**/SplashActivity.class", "**/SplashActivity\$*.class", "**/SplashActivityKt.class",
383
+ "**/SplashOverlay.class", "**/SplashOverlay\$*.class",
383
384
  "**/NativeCameraManager.class", "**/NativeCameraManager\$*.class",
384
385
  "**/camera/CameraSessionManager.class", "**/camera/CameraSessionManager\$*.class",
385
386
  "**/TransitionManager.class", "**/TransitionManager\$*.class",
@@ -68,6 +68,7 @@ class CustomWebView(
68
68
  private var visibleOfflineSnapshotUrl: String? = null
69
69
  @Volatile private var defaultRequestHeaders: Map<String, String> = emptyMap()
70
70
  var onPageStarted: (() -> Unit)? = null
71
+ var onInitialPageLoaded: (() -> Unit)? = null
71
72
 
72
73
  // Counters for asset loading statistics
73
74
  private var assetLoadAttempts = 0
@@ -1142,6 +1143,7 @@ class CustomWebView(
1142
1143
  if(!isInitialPageLoaded){
1143
1144
  isInitialPageLoaded = true
1144
1145
  metricsMonitor.markAppStartComplete()
1146
+ onInitialPageLoaded?.invoke()
1145
1147
  }
1146
1148
  url?.let { metricsMonitor.trackPageLoadEnd(it) }
1147
1149
 
@@ -52,6 +52,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
52
52
  private var currentUrl: String = ""
53
53
  private var edgeToEdgeEnabled = false
54
54
  private var latestSafeAreaInsets: SafeAreaInsets = SafeAreaInsets.ZERO
55
+ private var splashOverlay: SplashOverlay? = null
55
56
 
56
57
  private fun enableHardwareAcceleration() {
57
58
  if (!isHardwareAccelerationEnabled) {
@@ -319,6 +320,11 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
319
320
  binding = ActivityMainBinding.inflate(layoutInflater)
320
321
  supportActionBar?.hide()
321
322
  setContentView(binding.root)
323
+
324
+ // HTML splash handed off from SplashActivity (fresh launch only)
325
+ if (savedInstanceState == null && intent.getBooleanExtra(SplashActivity.EXTRA_HTML_SPLASH, false)) {
326
+ splashOverlay = SplashOverlay(this, properties).also { it.show() }
327
+ }
322
328
 
323
329
  // Initialize keyboard utility (pass webView so keyboard events reach WebPerfCollector)
324
330
  keyboardUtil = KeyboardUtil(this, binding.webviewContainer, binding.webview, edgeToEdgeEnabled)
@@ -334,6 +340,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
334
340
  progressBar = binding.progress,
335
341
  properties = properties
336
342
  )
343
+ customWebView.onInitialPageLoaded = { splashOverlay?.onMainPageLoaded() }
337
344
 
338
345
  // Handle state restoration
339
346
  if (savedInstanceState == null) {
@@ -482,6 +489,8 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
482
489
  }
483
490
 
484
491
  override fun onDestroy() {
492
+ splashOverlay?.release()
493
+ splashOverlay = null
485
494
  io.yourname.androidproject.utils.PerfEventBuffer.reset()
486
495
  // Log all performance metrics before destroying
487
496
  if (BuildConfig.DEBUG) {
@@ -1,6 +1,7 @@
1
1
  package io.yourname.androidproject
2
2
 
3
3
  import android.app.Activity
4
+ import android.content.Context
4
5
  import android.content.Intent
5
6
  import android.graphics.Color
6
7
  import android.os.Build
@@ -18,6 +19,11 @@ import java.util.Properties
18
19
 
19
20
  class SplashActivity : AppCompatActivity() {
20
21
 
22
+ companion object {
23
+ const val EXTRA_HTML_SPLASH = "io.yourname.androidproject.EXTRA_HTML_SPLASH"
24
+ const val HTML_SPLASH_ASSET = "splash/splashscreen.html"
25
+ }
26
+
21
27
  private lateinit var binding: ActivitySplashBinding
22
28
  private lateinit var properties: Properties
23
29
  private var isFinishing = false
@@ -38,50 +44,40 @@ class SplashActivity : AppCompatActivity() {
38
44
  return
39
45
  }
40
46
 
47
+ // HTML splash: forward straight to MainActivity without drawing, so the system starting
48
+ // window stays up until MainActivity draws the image + HTML overlay (no gap between two
49
+ // drawn activities)
50
+ if (hasHtmlSplash()) {
51
+ startHtmlSplashMainActivity()
52
+ return
53
+ }
54
+
41
55
  if (EdgeToEdgeUtils.isEnabled(properties)) {
42
56
  EdgeToEdgeUtils.apply(this, properties)
43
57
  }
44
58
 
45
59
  binding = ActivitySplashBinding.inflate(layoutInflater)
46
60
  setContentView(binding.root)
47
-
48
- configureSplashScreen()
61
+
62
+ configureSplashScreen(this, binding, properties)
49
63
  startSplashTimer()
50
64
  }
51
65
 
52
- private fun configureSplashScreen() {
53
- val backgroundColor = properties.getProperty("splashScreen.backgroundColor", "#ffffff")
54
- val imageWidth = properties.getProperty("splashScreen.imageWidth", "120").toIntOrNull() ?: 120
55
- val imageHeight = properties.getProperty("splashScreen.imageHeight", "120").toIntOrNull() ?: 120
56
- val cornerRadius = properties.getProperty("splashScreen.cornerRadius", "20").toFloatOrNull() ?: 20f
57
-
58
- try {
59
- binding.splashContainer.setBackgroundColor(Color.parseColor(backgroundColor))
60
- } catch (e: Exception) {
61
- binding.splashContainer.setBackgroundColor(Color.WHITE)
62
- }
63
-
64
- val widthPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, imageWidth.toFloat(), resources.displayMetrics).toInt()
65
- val heightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, imageHeight.toFloat(), resources.displayMetrics).toInt()
66
-
67
- binding.splashImage.layoutParams.apply {
68
- width = widthPx
69
- height = heightPx
70
- }
71
-
72
- val drawable = ContextCompat.getDrawable(this,
73
- resources.getIdentifier("splashscreen", "drawable", packageName).takeIf { it != 0 }
74
- ?: R.mipmap.ic_launcher
75
- ) ?: return
66
+ private fun hasHtmlSplash(): Boolean = try {
67
+ assets.open(HTML_SPLASH_ASSET).close()
68
+ true
69
+ } catch (e: Exception) {
70
+ false
71
+ }
76
72
 
77
- if (cornerRadius > 0) {
78
- val radiusPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, cornerRadius, resources.displayMetrics)
79
- val roundedDrawable = RoundedBitmapDrawableFactory.create(resources, drawable.toBitmap(widthPx, heightPx))
80
- roundedDrawable.cornerRadius = radiusPx
81
- binding.splashImage.setImageDrawable(roundedDrawable)
82
- } else {
83
- binding.splashImage.setImageDrawable(drawable)
84
- }
73
+ private fun startHtmlSplashMainActivity() {
74
+ isFinishing = true
75
+ startActivity(
76
+ Intent(this, MainActivity::class.java)
77
+ .putExtra(EXTRA_HTML_SPLASH, true)
78
+ .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
79
+ )
80
+ finish()
85
81
  }
86
82
 
87
83
  private fun startSplashTimer() {
@@ -114,3 +110,38 @@ class SplashActivity : AppCompatActivity() {
114
110
  finish()
115
111
  }
116
112
  }
113
+
114
+ internal fun configureSplashScreen(context: Context, binding: ActivitySplashBinding, properties: Properties) {
115
+ val backgroundColor = properties.getProperty("splashScreen.backgroundColor", "#ffffff")
116
+ val imageWidth = properties.getProperty("splashScreen.imageWidth", "120").toIntOrNull() ?: 120
117
+ val imageHeight = properties.getProperty("splashScreen.imageHeight", "120").toIntOrNull() ?: 120
118
+ val cornerRadius = properties.getProperty("splashScreen.cornerRadius", "20").toFloatOrNull() ?: 20f
119
+
120
+ try {
121
+ binding.splashContainer.setBackgroundColor(Color.parseColor(backgroundColor))
122
+ } catch (e: Exception) {
123
+ binding.splashContainer.setBackgroundColor(Color.WHITE)
124
+ }
125
+
126
+ val widthPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, imageWidth.toFloat(), context.resources.displayMetrics).toInt()
127
+ val heightPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, imageHeight.toFloat(), context.resources.displayMetrics).toInt()
128
+
129
+ binding.splashImage.layoutParams.apply {
130
+ width = widthPx
131
+ height = heightPx
132
+ }
133
+
134
+ val drawable = ContextCompat.getDrawable(context,
135
+ context.resources.getIdentifier("splashscreen", "drawable", context.packageName).takeIf { it != 0 }
136
+ ?: R.mipmap.ic_launcher
137
+ ) ?: return
138
+
139
+ if (cornerRadius > 0) {
140
+ val radiusPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, cornerRadius, context.resources.displayMetrics)
141
+ val roundedDrawable = RoundedBitmapDrawableFactory.create(context.resources, drawable.toBitmap(widthPx, heightPx))
142
+ roundedDrawable.cornerRadius = radiusPx
143
+ binding.splashImage.setImageDrawable(roundedDrawable)
144
+ } else {
145
+ binding.splashImage.setImageDrawable(drawable)
146
+ }
147
+ }
@@ -0,0 +1,109 @@
1
+ package io.yourname.androidproject
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.app.Activity
5
+ import android.graphics.Color
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.os.SystemClock
9
+ import android.view.ViewGroup
10
+ import android.webkit.WebView
11
+ import android.webkit.WebViewClient
12
+ import android.widget.FrameLayout
13
+ import io.yourname.androidproject.databinding.ActivitySplashBinding
14
+ import java.util.Properties
15
+
16
+ /**
17
+ * HTML splash shown above the main WebView until its first page finishes,
18
+ * held for at least splashScreen.minDuration ms and capped at MAX_DURATION_MS.
19
+ */
20
+ class SplashOverlay(private val activity: Activity, private val properties: Properties) {
21
+
22
+ companion object {
23
+ private const val MAX_DURATION_MS = 10_000L
24
+ private const val FADE_DURATION_MS = 300L
25
+ }
26
+
27
+ private val handler = Handler(Looper.getMainLooper())
28
+ private val minDuration = properties.getProperty("splashScreen.minDuration")?.toLongOrNull() ?: 0
29
+ private var root: FrameLayout? = null
30
+ private var webView: WebView? = null
31
+ private var shownAt = 0L
32
+ private var dismissed = false
33
+
34
+ @SuppressLint("SetJavaScriptEnabled")
35
+ fun show() {
36
+ val splashBinding = ActivitySplashBinding.inflate(activity.layoutInflater)
37
+ configureSplashScreen(activity, splashBinding, properties)
38
+
39
+ // No JS interfaces; only the bundled asset may load
40
+ val splashWebView = WebView(activity).apply {
41
+ settings.javaScriptEnabled = true
42
+ settings.blockNetworkLoads = true
43
+ settings.allowFileAccess = false
44
+ settings.allowContentAccess = false
45
+ setBackgroundColor(Color.TRANSPARENT)
46
+ // Chromium paints the page background before its content, so keep the native image
47
+ // in view until the HTML's first frame with content is on screen
48
+ alpha = 0f
49
+ webViewClient = object : WebViewClient() {
50
+ override fun onPageFinished(view: WebView, url: String?) {
51
+ view.postVisualStateCallback(0, object : WebView.VisualStateCallback() {
52
+ override fun onComplete(requestId: Long) {
53
+ view.alpha = 1f
54
+ }
55
+ })
56
+ }
57
+ }
58
+ loadUrl("file:///android_asset/${SplashActivity.HTML_SPLASH_ASSET}")
59
+ }
60
+ val matchParent = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
61
+ val overlayRoot = FrameLayout(activity).apply {
62
+ addView(splashBinding.root, matchParent)
63
+ addView(splashWebView, matchParent)
64
+ // Swallow touches so they don't reach the main WebView underneath
65
+ isClickable = true
66
+ isFocusable = true
67
+ }
68
+
69
+ activity.addContentView(overlayRoot, matchParent)
70
+
71
+ root = overlayRoot
72
+ webView = splashWebView
73
+ shownAt = SystemClock.elapsedRealtime()
74
+ handler.postDelayed({ dismiss() }, MAX_DURATION_MS)
75
+ }
76
+
77
+ fun onMainPageLoaded() {
78
+ if (dismissed) return
79
+ val remaining = minDuration - (SystemClock.elapsedRealtime() - shownAt)
80
+ handler.postDelayed({ dismiss() }, remaining.coerceAtLeast(0))
81
+ }
82
+
83
+ private fun dismiss() {
84
+ if (dismissed) return
85
+ dismissed = true
86
+ handler.removeCallbacksAndMessages(null)
87
+ val overlayRoot = root ?: return
88
+ overlayRoot.animate()
89
+ .alpha(0f)
90
+ .setDuration(FADE_DURATION_MS)
91
+ .withEndAction { destroy() }
92
+ .start()
93
+ }
94
+
95
+ fun release() {
96
+ dismissed = true
97
+ handler.removeCallbacksAndMessages(null)
98
+ root?.animate()?.cancel()
99
+ destroy()
100
+ }
101
+
102
+ private fun destroy() {
103
+ val overlayRoot = root ?: return
104
+ (overlayRoot.parent as? ViewGroup)?.removeView(overlayRoot)
105
+ webView?.destroy()
106
+ webView = null
107
+ root = null
108
+ }
109
+ }