catalyst-core 1.0.0 → 1.0.1-canary.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 (40) hide show
  1. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +18 -21
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
  8. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
  9. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +60 -11
  10. package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
  11. package/dist/native/bridge/hooks/aiProviders.js +27 -0
  12. package/dist/native/bridge/hooks/useAI.js +9 -8
  13. package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
  14. package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
  15. package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
  16. package/dist/native/buildAppIos.js +8 -2
  17. package/dist/native/buildIos/plugins.js +3 -1
  18. package/dist/native/internalPluginUtils.js +78 -0
  19. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
  20. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
  21. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
  22. package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
  23. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
  24. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
  25. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
  26. package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
  27. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
  28. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
  29. package/dist/native/pluginComposerIos.js +5 -1
  30. package/dist/server/expressServer.js +1 -0
  31. package/mcp_v2/conversion-tasks.json +10 -9
  32. package/mcp_v2/knowledge-base.json +34 -4
  33. package/mcp_v2/test/config.test.ts +52 -0
  34. package/mcp_v2/tools/config.d.ts +18 -0
  35. package/mcp_v2/tools/config.js +40 -0
  36. package/mcp_v2/tools/conversion.js +2 -2
  37. package/package.json +7 -6
  38. package/test/hooks/aiProviders.test.ts +33 -0
  39. package/test/hooks/useSafeNavigate.test.ts +21 -0
  40. package/test/native/aiPluginComposition.test.js +161 -0
@@ -66,7 +66,7 @@ class CustomWebView(
66
66
  private var lastTargetUrl: String? = null
67
67
  private var activeOfflineRouteOrigin: String? = null
68
68
  private var visibleOfflineSnapshotUrl: String? = null
69
- private var defaultRequestHeaders: Map<String, String> = emptyMap()
69
+ @Volatile private var defaultRequestHeaders: Map<String, String> = emptyMap()
70
70
  var onPageStarted: (() -> Unit)? = null
71
71
 
72
72
  // Counters for asset loading statistics
@@ -9,7 +9,6 @@ import android.view.WindowManager
9
9
  import androidx.activity.OnBackPressedCallback
10
10
  import androidx.appcompat.app.AppCompatActivity
11
11
  import androidx.core.view.ViewCompat
12
- import androidx.core.view.WindowCompat
13
12
 
14
13
  import org.json.JSONObject
15
14
  import java.util.Properties
@@ -17,6 +16,7 @@ import io.yourname.androidproject.databinding.ActivityMainBinding
17
16
  import io.yourname.androidproject.NativeBridge
18
17
  import io.yourname.androidproject.plugins.PluginBridge
19
18
  import io.yourname.androidproject.utils.BridgeUtils
19
+ import io.yourname.androidproject.utils.EdgeToEdgeUtils
20
20
  import io.yourname.androidproject.utils.KeyboardUtil
21
21
  import io.yourname.androidproject.utils.NetworkUtils
22
22
  import io.yourname.androidproject.utils.NotificationConstants
@@ -31,7 +31,9 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
31
31
 
32
32
  companion object {
33
33
  private const val TAG = "WebViewDebug"
34
- private const val PREFS_NAME = "safe_area_prefs"
34
+ // v2: values are CSS px; the legacy "safe_area_prefs" file held physical px
35
+ private const val PREFS_NAME = "safe_area_prefs_v2"
36
+ private const val LEGACY_PREFS_NAME = "safe_area_prefs"
35
37
  private const val PREF_SAFE_AREA_TOP = "safe_area_top"
36
38
  private const val PREF_SAFE_AREA_RIGHT = "safe_area_right"
37
39
  private const val PREF_SAFE_AREA_BOTTOM = "safe_area_bottom"
@@ -75,25 +77,27 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
75
77
  }
76
78
 
77
79
  private fun configureEdgeToEdge() {
78
- edgeToEdgeEnabled = properties
79
- .getProperty("edgeToEdge.enabled", "false")
80
- .equals("true", ignoreCase = true)
80
+ edgeToEdgeEnabled = EdgeToEdgeUtils.isEnabled(properties)
81
81
 
82
82
  if (edgeToEdgeEnabled) {
83
- WindowCompat.setDecorFitsSystemWindows(window, false)
84
- } else {
85
- WindowCompat.setDecorFitsSystemWindows(window, true)
83
+ EdgeToEdgeUtils.apply(this, properties)
84
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
86
85
  }
87
86
  }
88
87
 
89
88
  private fun setupSafeAreaHandling() {
90
89
  val rootView = binding.root
91
- val cachedInsets = loadCachedSafeAreaInsets()
90
+ // Legacy cache held physical px; drop it once (no-op when absent)
91
+ deleteSharedPreferences(LEGACY_PREFS_NAME)
92
+
93
+ // Edge-to-edge disabled: insets are always ZERO, so skip the cache entirely
94
+ val cachedInsets = if (edgeToEdgeEnabled) loadCachedSafeAreaInsets() else null
92
95
 
93
96
  if (BuildConfig.DEBUG) {
94
97
  Log.d(TAG, "📋 Cache lookup result: $cachedInsets")
95
98
  }
96
99
 
100
+ // Synchronous initial read so the first loadUrl already carries safe area headers
97
101
  if (cachedInsets != null) {
98
102
  latestSafeAreaInsets = cachedInsets
99
103
  customWebView.setDefaultRequestHeaders(buildSafeAreaHeaders())
@@ -111,24 +115,34 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
111
115
  }
112
116
  }
113
117
 
114
- rootView.post {
115
- val postLayoutInsets = SafeAreaUtils.getSafeAreaInsets(window, rootView, edgeToEdgeEnabled)
118
+ // Track inset changes (first layout, rotation, cutout, bar visibility); insets stay unconsumed
119
+ var lastCachedInsets = cachedInsets
120
+ ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets ->
121
+ val updatedInsets = SafeAreaUtils.fromWindowInsets(
122
+ insets,
123
+ edgeToEdgeEnabled,
124
+ view.resources.displayMetrics.density
125
+ )
116
126
 
117
- if (postLayoutInsets != latestSafeAreaInsets) {
118
- latestSafeAreaInsets = postLayoutInsets
127
+ if (updatedInsets != latestSafeAreaInsets) {
128
+ latestSafeAreaInsets = updatedInsets
119
129
  customWebView.setDefaultRequestHeaders(buildSafeAreaHeaders())
120
- notifySafeAreaUpdate(postLayoutInsets)
130
+ notifySafeAreaUpdate(updatedInsets)
121
131
 
122
132
  if (BuildConfig.DEBUG) {
123
- Log.d(TAG, "🔄 Updated safe area insets: $postLayoutInsets")
133
+ Log.d(TAG, "🔄 Updated safe area insets: $updatedInsets")
124
134
  }
125
135
  }
126
136
 
127
137
  // Save to cache if not already cached or if values changed
128
- if (cachedInsets == null || cachedInsets != postLayoutInsets) {
129
- saveSafeAreaInsetsToCache(postLayoutInsets)
138
+ if (edgeToEdgeEnabled && updatedInsets != lastCachedInsets) {
139
+ saveSafeAreaInsetsToCache(updatedInsets)
140
+ lastCachedInsets = updatedInsets
130
141
  }
142
+
143
+ insets
131
144
  }
145
+ ViewCompat.requestApplyInsets(rootView)
132
146
  }
133
147
 
134
148
  private fun buildSafeAreaHeaders(): Map<String, String> = mapOf(
@@ -222,29 +236,25 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
222
236
  return
223
237
  }
224
238
 
225
- val saved = getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE).edit().apply {
239
+ val prefs = getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
240
+ prefs.edit().apply {
226
241
  putInt(PREF_SAFE_AREA_TOP, insets.top)
227
242
  putInt(PREF_SAFE_AREA_RIGHT, insets.right)
228
243
  putInt(PREF_SAFE_AREA_BOTTOM, insets.bottom)
229
244
  putInt(PREF_SAFE_AREA_LEFT, insets.left)
230
245
  putBoolean(PREF_SAFE_AREA_CACHED, true)
231
- }.commit() // Use commit() instead of apply() to ensure immediate persistence
246
+ }.apply() // Async disk write; the in-memory map is updated immediately
232
247
 
233
248
  if (BuildConfig.DEBUG) {
234
- if (saved) {
235
- // Verify by reading back immediately
236
- val prefs = getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
237
- val verified = SafeAreaInsets(
238
- top = prefs.getInt(PREF_SAFE_AREA_TOP, -1),
239
- right = prefs.getInt(PREF_SAFE_AREA_RIGHT, -1),
240
- bottom = prefs.getInt(PREF_SAFE_AREA_BOTTOM, -1),
241
- left = prefs.getInt(PREF_SAFE_AREA_LEFT, -1)
242
- )
243
- Log.d(TAG, "💾 Cached safe area insets: $insets")
244
- Log.d(TAG, "✓ Verified cached values: $verified")
245
- } else {
246
- Log.e(TAG, "❌ Failed to cache safe area insets: $insets")
247
- }
249
+ // Read back from the in-memory map (disk write may still be pending)
250
+ val verified = SafeAreaInsets(
251
+ top = prefs.getInt(PREF_SAFE_AREA_TOP, -1),
252
+ right = prefs.getInt(PREF_SAFE_AREA_RIGHT, -1),
253
+ bottom = prefs.getInt(PREF_SAFE_AREA_BOTTOM, -1),
254
+ left = prefs.getInt(PREF_SAFE_AREA_LEFT, -1)
255
+ )
256
+ Log.d(TAG, "💾 Cached safe area insets: $insets")
257
+ Log.d(TAG, "✓ Verified cached values: $verified")
248
258
  }
249
259
  }
250
260
 
@@ -311,7 +321,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
311
321
  setContentView(binding.root)
312
322
 
313
323
  // Initialize keyboard utility (pass webView so keyboard events reach WebPerfCollector)
314
- keyboardUtil = KeyboardUtil(this, binding.webviewContainer, binding.webview)
324
+ keyboardUtil = KeyboardUtil(this, binding.webviewContainer, binding.webview, edgeToEdgeEnabled)
315
325
  keyboardUtil.initialize()
316
326
 
317
327
  // Enable hardware acceleration for the window
@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
13
13
  import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
14
14
  import androidx.core.graphics.drawable.toBitmap
15
15
  import io.yourname.androidproject.databinding.ActivitySplashBinding
16
+ import io.yourname.androidproject.utils.EdgeToEdgeUtils
16
17
  import java.util.Properties
17
18
 
18
19
  class SplashActivity : AppCompatActivity() {
@@ -37,6 +38,10 @@ class SplashActivity : AppCompatActivity() {
37
38
  return
38
39
  }
39
40
 
41
+ if (EdgeToEdgeUtils.isEnabled(properties)) {
42
+ EdgeToEdgeUtils.apply(this, properties)
43
+ }
44
+
40
45
  binding = ActivitySplashBinding.inflate(layoutInflater)
41
46
  setContentView(binding.root)
42
47
 
@@ -0,0 +1,53 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ import android.graphics.Color
4
+ import android.util.Log
5
+ import androidx.activity.ComponentActivity
6
+ import androidx.activity.SystemBarStyle
7
+ import androidx.activity.enableEdgeToEdge
8
+ import io.yourname.androidproject.BuildConfig
9
+ import java.util.Properties
10
+
11
+ /**
12
+ * Edge-to-edge window setup shared by SplashActivity and MainActivity.
13
+ *
14
+ * Reads `edgeToEdge.statusBarStyle` from webview_config.properties:
15
+ * - "auto" (default): androidx defaults, icons follow the system theme
16
+ * - "dark-content": dark icons, for light page backgrounds
17
+ * - "light-content": light icons, for dark page backgrounds
18
+ */
19
+ object EdgeToEdgeUtils {
20
+
21
+ private const val TAG = "EdgeToEdgeUtils"
22
+
23
+ // Copies of androidx.activity's internal DefaultLightScrim / DefaultDarkScrim (activity 1.8.0)
24
+ private val DEFAULT_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
25
+ private val DEFAULT_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
26
+
27
+ fun isEnabled(properties: Properties): Boolean =
28
+ properties.getProperty("edgeToEdge.enabled", "false").equals("true", ignoreCase = true)
29
+
30
+ /**
31
+ * Draws the activity behind the system bars and applies the configured bar style.
32
+ * Must be called before setContentView.
33
+ */
34
+ fun apply(activity: ComponentActivity, properties: Properties) {
35
+ when (val style = properties.getProperty("edgeToEdge.statusBarStyle", "auto")) {
36
+ "auto" -> activity.enableEdgeToEdge()
37
+ "dark-content" -> activity.enableEdgeToEdge(
38
+ statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT),
39
+ navigationBarStyle = SystemBarStyle.light(DEFAULT_LIGHT_SCRIM, DEFAULT_DARK_SCRIM)
40
+ )
41
+ "light-content" -> activity.enableEdgeToEdge(
42
+ statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
43
+ navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)
44
+ )
45
+ else -> {
46
+ if (BuildConfig.DEBUG) {
47
+ Log.d(TAG, "⚠️ Unknown edgeToEdge.statusBarStyle '$style', falling back to auto")
48
+ }
49
+ activity.enableEdgeToEdge()
50
+ }
51
+ }
52
+ }
53
+ }
@@ -9,12 +9,16 @@ import android.view.ViewGroup
9
9
  import android.view.ViewTreeObserver
10
10
  import android.webkit.WebView
11
11
  import androidx.constraintlayout.widget.ConstraintLayout
12
+ import androidx.core.graphics.Insets
13
+ import androidx.core.view.ViewCompat
14
+ import androidx.core.view.WindowInsetsCompat
12
15
  import org.json.JSONObject
13
16
 
14
17
  class KeyboardUtil(
15
18
  private val activity: Activity,
16
19
  private val webViewContainer: View,
17
20
  private val webView: WebView? = null,
21
+ private val edgeToEdgeEnabled: Boolean = false,
18
22
  ) {
19
23
  private var originalHeight: Int = 0
20
24
  private var listener: ViewTreeObserver.OnGlobalLayoutListener? = null
@@ -51,6 +55,22 @@ class KeyboardUtil(
51
55
  fun initialize() {
52
56
  val rootView = activity.findViewById<ViewGroup>(android.R.id.content)
53
57
 
58
+ if (edgeToEdgeEnabled) {
59
+ // Root stays full-screen in edge-to-edge, so pin the resized container to the top instead of centering it
60
+ (webViewContainer.layoutParams as ConstraintLayout.LayoutParams).verticalBias = 0f
61
+
62
+ // Strip IME insets from the WebView: we already shrink the container, and the WebView
63
+ // would otherwise subtract the keyboard a second time from its visual viewport
64
+ webView?.let { wv ->
65
+ ViewCompat.setOnApplyWindowInsetsListener(wv) { v, insets ->
66
+ val withoutIme = WindowInsetsCompat.Builder(insets)
67
+ .setInsets(WindowInsetsCompat.Type.ime(), Insets.NONE)
68
+ .build()
69
+ ViewCompat.onApplyWindowInsets(v, withoutIme)
70
+ }
71
+ }
72
+ }
73
+
54
74
  webViewContainer.post {
55
75
  originalHeight = webViewContainer.height
56
76
  }
@@ -58,8 +78,16 @@ class KeyboardUtil(
58
78
  val rect = Rect()
59
79
  rootView.getWindowVisibleDisplayFrame(rect)
60
80
  val screenHeight = activity.resources.displayMetrics.heightPixels
61
- val visibleHeight = rect.height()
62
- val keyboardHeight = screenHeight - visibleHeight
81
+ val keyboardHeight = if (edgeToEdgeEnabled) {
82
+ // Edge-to-edge: rootView spans behind the system bars, so measure how far the
83
+ // visible frame's bottom sits above rootView's bottom (screen coords; works in split-screen)
84
+ val location = IntArray(2)
85
+ rootView.getLocationOnScreen(location)
86
+ maxOf(0, (location[1] + rootView.height) - rect.bottom)
87
+ } else {
88
+ val visibleHeight = rect.height()
89
+ screenHeight - visibleHeight
90
+ }
63
91
 
64
92
  // Use density-independent threshold and consider screen size
65
93
  // Also add a minimum percentage of screen height as additional validation
@@ -4,6 +4,7 @@ import android.view.View
4
4
  import android.view.Window
5
5
  import androidx.core.view.ViewCompat
6
6
  import androidx.core.view.WindowInsetsCompat
7
+ import kotlin.math.ceil
7
8
  import kotlin.math.max
8
9
 
9
10
  data class SafeAreaInsets(
@@ -26,37 +27,33 @@ data class SafeAreaInsets(
26
27
 
27
28
  object SafeAreaUtils {
28
29
  /**
29
- * Computes safe area insets from window insets.
30
- * - Edge-to-edge disabled: Returns system bars only
31
- * - Edge-to-edge enabled: Returns max(system bars, cutout) per edge
30
+ * Computes safe area insets from window insets, in CSS px.
31
+ * CSS px equal dp here: the WebView has no wide-viewport scaling, so
32
+ * devicePixelRatio == density and px / density gives CSS px.
33
+ * - Edge-to-edge disabled: Returns ZERO (the system already insets the WebView, iOS parity)
34
+ * - Edge-to-edge enabled: Returns ceil(max(system bars ignoring visibility, cutout) / density) per edge
32
35
  */
33
- fun fromWindowInsets(insets: WindowInsetsCompat?, edgeToEdgeEnabled: Boolean): SafeAreaInsets {
34
- if (insets == null) return SafeAreaInsets.ZERO
35
-
36
- val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
37
-
38
- if (!edgeToEdgeEnabled) {
39
- return SafeAreaInsets(
40
- top = max(0, systemBars.top),
41
- right = max(0, systemBars.right),
42
- bottom = max(0, systemBars.bottom),
43
- left = max(0, systemBars.left)
44
- )
45
- }
36
+ fun fromWindowInsets(insets: WindowInsetsCompat?, edgeToEdgeEnabled: Boolean, density: Float): SafeAreaInsets {
37
+ if (insets == null || !edgeToEdgeEnabled) return SafeAreaInsets.ZERO
46
38
 
39
+ // Ignore visibility: the IME window hosts the nav bar, so the visible systemBars
40
+ // bottom grows to the keyboard height while the keyboard is open
41
+ val systemBars = insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
47
42
  val cutoutInsets = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
48
43
 
49
44
  return SafeAreaInsets(
50
- top = max(0, max(systemBars.top, cutoutInsets.top)),
51
- right = max(0, max(systemBars.right, cutoutInsets.right)),
52
- bottom = max(0, max(systemBars.bottom, cutoutInsets.bottom)),
53
- left = max(0, max(systemBars.left, cutoutInsets.left))
45
+ top = toCssPx(max(systemBars.top, cutoutInsets.top), density),
46
+ right = toCssPx(max(systemBars.right, cutoutInsets.right), density),
47
+ bottom = toCssPx(max(systemBars.bottom, cutoutInsets.bottom), density),
48
+ left = toCssPx(max(systemBars.left, cutoutInsets.left), density)
54
49
  )
55
50
  }
56
51
 
57
52
  fun getSafeAreaInsets(window: Window, rootView: View, edgeToEdgeEnabled: Boolean): SafeAreaInsets {
58
53
  val windowInsets =
59
54
  ViewCompat.getRootWindowInsets(window.decorView) ?: ViewCompat.getRootWindowInsets(rootView)
60
- return fromWindowInsets(windowInsets, edgeToEdgeEnabled)
55
+ return fromWindowInsets(windowInsets, edgeToEdgeEnabled, rootView.resources.displayMetrics.density)
61
56
  }
57
+
58
+ private fun toCssPx(px: Int, density: Float): Int = max(0, ceil(px / density).toInt())
62
59
  }
@@ -209,4 +209,30 @@ class WebCacheManagerTest {
209
209
  val manager = WebCacheManager(context)
210
210
  manager.cleanup()
211
211
  }
212
+
213
+ /**
214
+ * cleanup() wraps its work in withContext(Dispatchers.IO). When the caller is
215
+ * already on Dispatchers.IO the call completes inline without suspending, which
216
+ * is a distinct code path (the final `return Unit`). The tests above call from a
217
+ * test dispatcher, which always suspends, so that path only ran in CI when some
218
+ * other test happened to call from an IO thread, moving the coverage number by
219
+ * one line between runs. Calling from IO explicitly makes it deterministic.
220
+ */
221
+ @Test
222
+ fun `cleanup called from an IO dispatcher completes without suspending`() = kotlinx.coroutines.runBlocking {
223
+ val manager = WebCacheManager(context)
224
+ val webviewCacheDir = File(cacheDir, "webview_cache")
225
+ val freshFile = File(webviewCacheDir, "fresh-entry")
226
+ freshFile.writeText("fresh")
227
+ val expiredFile = File(webviewCacheDir, "expired-entry")
228
+ expiredFile.writeText("expired")
229
+ expiredFile.setLastModified(System.currentTimeMillis() - java.util.concurrent.TimeUnit.HOURS.toMillis(26))
230
+
231
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
232
+ manager.cleanup()
233
+ }
234
+
235
+ assertTrue("Fresh file should survive cleanup", freshFile.exists())
236
+ assertFalse("Expired file should be deleted by cleanup", expiredFile.exists())
237
+ }
212
238
  }
@@ -0,0 +1,87 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ import android.content.res.Configuration
4
+ import android.content.res.Resources
5
+ import android.view.View
6
+ import android.view.Window
7
+ import androidx.activity.ComponentActivity
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.mockito.kotlin.atLeastOnce
12
+ import org.mockito.kotlin.doReturn
13
+ import org.mockito.kotlin.mock
14
+ import org.mockito.kotlin.verify
15
+ import java.util.Properties
16
+
17
+ /**
18
+ * Unit tests for EdgeToEdgeUtils, the edge-to-edge window setup shared by
19
+ * SplashActivity and MainActivity (previously 0/15 lines covered).
20
+ *
21
+ * `apply` hands off to androidx.activity's `enableEdgeToEdge`, which reads
22
+ * the window's decor view and its resources to decide the icon colors, so
23
+ * the activity mock is wired with just that chain. Under the mockable
24
+ * android.jar (isReturnDefaultValues = true) Build.VERSION.SDK_INT is 0, so
25
+ * androidx takes its base (no-op) implementation: these tests assert that
26
+ * every `edgeToEdge.statusBarStyle` value, including an unknown one, reaches
27
+ * the window without throwing.
28
+ */
29
+ class EdgeToEdgeUtilsTest {
30
+
31
+ private fun properties(vararg pairs: Pair<String, String>) = Properties().apply {
32
+ pairs.forEach { (k, v) -> setProperty(k, v) }
33
+ }
34
+
35
+ private fun mockActivity(): ComponentActivity {
36
+ val configuration = Configuration()
37
+ val resources = mock<Resources> { on { getConfiguration() } doReturn configuration }
38
+ val decorView = mock<View> { on { getResources() } doReturn resources }
39
+ val window = mock<Window> { on { getDecorView() } doReturn decorView }
40
+ return mock { on { getWindow() } doReturn window }
41
+ }
42
+
43
+ @Test
44
+ fun isEnabled_defaultsToFalseWhenKeyMissing() {
45
+ assertFalse(EdgeToEdgeUtils.isEnabled(Properties()))
46
+ }
47
+
48
+ @Test
49
+ fun isEnabled_trueIsCaseInsensitive() {
50
+ assertTrue(EdgeToEdgeUtils.isEnabled(properties("edgeToEdge.enabled" to "true")))
51
+ assertTrue(EdgeToEdgeUtils.isEnabled(properties("edgeToEdge.enabled" to "TRUE")))
52
+ }
53
+
54
+ @Test
55
+ fun isEnabled_anyOtherValueIsFalse() {
56
+ assertFalse(EdgeToEdgeUtils.isEnabled(properties("edgeToEdge.enabled" to "false")))
57
+ assertFalse(EdgeToEdgeUtils.isEnabled(properties("edgeToEdge.enabled" to "yes")))
58
+ }
59
+
60
+ @Test
61
+ fun apply_autoIsTheDefaultStyle() {
62
+ val activity = mockActivity()
63
+ EdgeToEdgeUtils.apply(activity, Properties())
64
+ verify(activity, atLeastOnce()).window
65
+ }
66
+
67
+ @Test
68
+ fun apply_darkContentStyle() {
69
+ val activity = mockActivity()
70
+ EdgeToEdgeUtils.apply(activity, properties("edgeToEdge.statusBarStyle" to "dark-content"))
71
+ verify(activity, atLeastOnce()).window
72
+ }
73
+
74
+ @Test
75
+ fun apply_lightContentStyle() {
76
+ val activity = mockActivity()
77
+ EdgeToEdgeUtils.apply(activity, properties("edgeToEdge.statusBarStyle" to "light-content"))
78
+ verify(activity, atLeastOnce()).window
79
+ }
80
+
81
+ @Test
82
+ fun apply_unknownStyleFallsBackToAuto() {
83
+ val activity = mockActivity()
84
+ EdgeToEdgeUtils.apply(activity, properties("edgeToEdge.statusBarStyle" to "sparkly"))
85
+ verify(activity, atLeastOnce()).window
86
+ }
87
+ }
@@ -1,5 +1,9 @@
1
1
  package io.yourname.androidproject.utils
2
2
 
3
+ import android.content.res.Resources
4
+ import android.util.DisplayMetrics
5
+ import android.view.View
6
+ import android.view.Window
3
7
  import androidx.core.graphics.Insets
4
8
  import androidx.core.view.WindowInsetsCompat
5
9
  import org.junit.Assert.assertEquals
@@ -28,9 +32,14 @@ import org.mockito.kotlin.whenever
28
32
  */
29
33
  class SafeAreaUtilsTest {
30
34
 
31
- private fun windowInsetsCompatWith(systemBars: Insets, cutout: Insets = Insets.NONE): WindowInsetsCompat {
35
+ private fun windowInsetsCompatWith(
36
+ systemBars: Insets,
37
+ cutout: Insets = Insets.NONE,
38
+ visibleSystemBars: Insets = systemBars
39
+ ): WindowInsetsCompat {
32
40
  return mock {
33
- on { getInsets(WindowInsetsCompat.Type.systemBars()) } doReturn systemBars
41
+ on { getInsets(WindowInsetsCompat.Type.systemBars()) } doReturn visibleSystemBars
42
+ on { getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars()) } doReturn systemBars
34
43
  on { getInsets(WindowInsetsCompat.Type.displayCutout()) } doReturn cutout
35
44
  }
36
45
  }
@@ -41,22 +50,40 @@ class SafeAreaUtilsTest {
41
50
 
42
51
  @Test
43
52
  fun `fromWindowInsets returns ZERO when insets is null`() {
44
- val result = SafeAreaUtils.fromWindowInsets(null, edgeToEdgeEnabled = true)
53
+ val result = SafeAreaUtils.fromWindowInsets(null, edgeToEdgeEnabled = true, density = 1f)
45
54
  assertEquals(SafeAreaInsets.ZERO, result)
46
55
  }
47
56
 
48
57
  // ============================================================
49
- // edge-to-edge disabled -- system bars only
58
+ // getSafeAreaInsets passthrough
50
59
  // ============================================================
51
60
 
52
61
  @Test
53
- fun `fromWindowInsets with edge-to-edge disabled returns system bar insets directly`() {
62
+ fun `getSafeAreaInsets returns ZERO when the window has no root insets yet`() {
63
+ // Under the mockable android.jar Build.VERSION.SDK_INT is 0, so
64
+ // ViewCompat.getRootWindowInsets returns null for both views (no
65
+ // static mocking needed) and the passthrough delegates a null.
66
+ val resources = mock<Resources> { on { getDisplayMetrics() } doReturn DisplayMetrics() }
67
+ val rootView = mock<View> { on { getResources() } doReturn resources }
68
+ val window = mock<Window> { on { getDecorView() } doReturn mock<View>() }
69
+
70
+ val result = SafeAreaUtils.getSafeAreaInsets(window, rootView, edgeToEdgeEnabled = true)
71
+
72
+ assertEquals(SafeAreaInsets.ZERO, result)
73
+ }
74
+
75
+ // ============================================================
76
+ // edge-to-edge disabled -- always ZERO (system already insets the WebView)
77
+ // ============================================================
78
+
79
+ @Test
80
+ fun `fromWindowInsets with edge-to-edge disabled returns ZERO`() {
54
81
  val systemBars = Insets.of(10, 20, 30, 40)
55
82
  val windowInsets = windowInsetsCompatWith(systemBars)
56
83
 
57
- val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false)
84
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false, density = 1f)
58
85
 
59
- assertEquals(SafeAreaInsets(top = 20, right = 30, bottom = 40, left = 10), result)
86
+ assertEquals(SafeAreaInsets.ZERO, result)
60
87
  }
61
88
 
62
89
  @Test
@@ -65,9 +92,9 @@ class SafeAreaUtilsTest {
65
92
  val cutout = Insets.of(100, 100, 100, 100)
66
93
  val windowInsets = windowInsetsCompatWith(systemBars, cutout)
67
94
 
68
- val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false)
95
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false, density = 1f)
69
96
 
70
- assertEquals(SafeAreaInsets(top = 20, right = 30, bottom = 40, left = 10), result)
97
+ assertEquals(SafeAreaInsets.ZERO, result)
71
98
  }
72
99
 
73
100
  // ============================================================
@@ -80,7 +107,7 @@ class SafeAreaUtilsTest {
80
107
  val cutout = Insets.of(0, 20, 60, 15)
81
108
  val windowInsets = windowInsetsCompatWith(systemBars, cutout)
82
109
 
83
- val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true)
110
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 1f)
84
111
 
85
112
  // left: max(10, 0)=10, top: max(50, 20)=50, right: max(30, 60)=60, bottom: max(5, 15)=15
86
113
  assertEquals(SafeAreaInsets(top = 50, right = 60, bottom = 15, left = 10), result)
@@ -91,11 +118,33 @@ class SafeAreaUtilsTest {
91
118
  val systemBars = Insets.of(10, 20, 30, 40)
92
119
  val windowInsets = windowInsetsCompatWith(systemBars, Insets.NONE)
93
120
 
94
- val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true)
121
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 1f)
95
122
 
96
123
  assertEquals(SafeAreaInsets(top = 20, right = 30, bottom = 40, left = 10), result)
97
124
  }
98
125
 
126
+ @Test
127
+ fun `fromWindowInsets with edge-to-edge enabled converts physical px to CSS px by density`() {
128
+ val systemBars = Insets.of(0, 66, 0, 0)
129
+ val windowInsets = windowInsetsCompatWith(systemBars)
130
+
131
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 2.75f)
132
+
133
+ assertEquals(SafeAreaInsets(top = 24, right = 0, bottom = 0, left = 0), result)
134
+ }
135
+
136
+ @Test
137
+ fun `fromWindowInsets with edge-to-edge enabled ignores the IME-inflated visible system bar bottom`() {
138
+ val windowInsets = windowInsetsCompatWith(
139
+ systemBars = Insets.of(0, 0, 0, 63),
140
+ visibleSystemBars = Insets.of(0, 0, 0, 883)
141
+ )
142
+
143
+ val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 2.625f)
144
+
145
+ assertEquals(SafeAreaInsets(top = 0, right = 0, bottom = 24, left = 0), result)
146
+ }
147
+
99
148
  // ============================================================
100
149
  // SafeAreaInsets.toMap()
101
150
  // ============================================================
@@ -0,0 +1,5 @@
1
+ export declare function resolveMode(provider: string | undefined): "local" | "native" | "cloud";
2
+ /** Engine to pin for the native provider: an explicit option wins, then the provider alias, then config. */
3
+ export declare function resolveNativeEngine(provider: string | undefined, explicitEngine: string | undefined, config: {
4
+ engine?: string;
5
+ } | null | undefined): string | undefined;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.resolveMode = resolveMode;
7
+ exports.resolveNativeEngine = resolveNativeEngine;
8
+ // On-device providers. "native" lets the platform pick (iOS: LiteRT-LM if its model is downloaded, else
9
+ // Apple Foundation Models, else LiteRT-LM); "litert" and "foundation-models" pin an engine. They can be set
10
+ // per call (useAI({ provider })) or app-wide in AI_CONFIG.browser.provider, optionally with
11
+ // AI_CONFIG.browser.engine instead of an alias.
12
+ const NATIVE_ENGINE_BY_PROVIDER = {
13
+ native: undefined,
14
+ litert: "litert",
15
+ "foundation-models": "foundation-models"
16
+ };
17
+ function resolveMode(provider) {
18
+ if (provider === "transformers") return "local";
19
+ if (provider !== undefined && Object.prototype.hasOwnProperty.call(NATIVE_ENGINE_BY_PROVIDER, provider)) return "native";
20
+ return "cloud";
21
+ }
22
+
23
+ /** Engine to pin for the native provider: an explicit option wins, then the provider alias, then config. */
24
+ function resolveNativeEngine(provider, explicitEngine, config) {
25
+ const aliasEngine = provider !== undefined && Object.prototype.hasOwnProperty.call(NATIVE_ENGINE_BY_PROVIDER, provider) ? NATIVE_ENGINE_BY_PROVIDER[provider] : undefined;
26
+ return explicitEngine ?? aliasEngine ?? config?.engine;
27
+ }