catalyst-core 1.0.0 → 1.0.1-canary.1
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/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +44 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +5 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/EdgeToEdgeUtils.kt +53 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +30 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/SafeAreaUtils.kt +15 -20
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/WebCacheManagerTest.kt +26 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/EdgeToEdgeUtilsTest.kt +87 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/utils/SafeAreaUtilsTest.kt +41 -9
- package/dist/native/bridge/hooks/aiProviders.d.ts +5 -0
- package/dist/native/bridge/hooks/aiProviders.js +27 -0
- package/dist/native/bridge/hooks/useAI.js +9 -8
- package/dist/native/bridge/hooks/useNativeTransition.js +2 -2
- package/dist/native/bridge/hooks/useSafeNavigate.d.ts +10 -0
- package/dist/native/bridge/hooks/useSafeNavigate.js +22 -0
- package/dist/native/buildAppIos.js +8 -2
- package/dist/native/buildIos/plugins.js +3 -1
- package/dist/native/internalPluginUtils.js +78 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+NativeAI.swift +221 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils+Requests.swift +215 -0
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/FrameworkServerUtils.swift +105 -100
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Utils/NativeAI.swift +76 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsConnectionHandlerTests.swift +133 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsLoopbackTests.swift +157 -40
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/FrameworkServerUtilsNativeAITests.swift +351 -0
- package/dist/native/iosnativeWebView/CoreLogic/Tests/CatalystCoreLogicTests/NetworkMonitorTests.swift +17 -10
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +15 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +23 -0
- package/dist/native/pluginComposerIos.js +5 -1
- package/dist/otel.js +34 -1
- package/dist/server/expressServer.js +1 -0
- package/dist/server/renderer/handler.jsx +23 -0
- package/dist/vite/vite.config.client.js +1 -1
- package/dist/vite/vite.config.js +0 -4
- package/dist/web-router/components/MetaTag.jsx +6 -13
- package/dist/web-router/components/MetaTag.test.jsx +40 -33
- package/mcp_v2/conversion-tasks.json +10 -9
- package/mcp_v2/knowledge-base.json +34 -4
- package/mcp_v2/test/config.test.ts +52 -0
- package/mcp_v2/tools/config.d.ts +18 -0
- package/mcp_v2/tools/config.js +40 -0
- package/mcp_v2/tools/conversion.js +2 -2
- package/package.json +6 -10
- package/test/hooks/aiProviders.test.ts +33 -0
- package/test/hooks/useSafeNavigate.test.ts +21 -0
- package/test/native/aiPluginComposition.test.js +161 -0
- package/test/server/handler.test.ts +27 -0
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt
CHANGED
|
@@ -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
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
118
|
-
latestSafeAreaInsets =
|
|
127
|
+
if (updatedInsets != latestSafeAreaInsets) {
|
|
128
|
+
latestSafeAreaInsets = updatedInsets
|
|
119
129
|
customWebView.setDefaultRequestHeaders(buildSafeAreaHeaders())
|
|
120
|
-
notifySafeAreaUpdate(
|
|
130
|
+
notifySafeAreaUpdate(updatedInsets)
|
|
121
131
|
|
|
122
132
|
if (BuildConfig.DEBUG) {
|
|
123
|
-
Log.d(TAG, "🔄 Updated safe area insets: $
|
|
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 (
|
|
129
|
-
saveSafeAreaInsetsToCache(
|
|
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
|
|
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
|
-
}.
|
|
246
|
+
}.apply() // Async disk write; the in-memory map is updated immediately
|
|
232
247
|
|
|
233
248
|
if (BuildConfig.DEBUG) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt
CHANGED
|
@@ -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
|
|
62
|
-
|
|
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,31 @@ data class SafeAreaInsets(
|
|
|
26
27
|
|
|
27
28
|
object SafeAreaUtils {
|
|
28
29
|
/**
|
|
29
|
-
* Computes safe area insets from window insets.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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, cutout) / density) per edge
|
|
32
35
|
*/
|
|
33
|
-
fun fromWindowInsets(insets: WindowInsetsCompat?, edgeToEdgeEnabled: Boolean): SafeAreaInsets {
|
|
34
|
-
if (insets == null) return SafeAreaInsets.ZERO
|
|
36
|
+
fun fromWindowInsets(insets: WindowInsetsCompat?, edgeToEdgeEnabled: Boolean, density: Float): SafeAreaInsets {
|
|
37
|
+
if (insets == null || !edgeToEdgeEnabled) return SafeAreaInsets.ZERO
|
|
35
38
|
|
|
36
39
|
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
|
-
}
|
|
46
|
-
|
|
47
40
|
val cutoutInsets = insets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
|
48
41
|
|
|
49
42
|
return SafeAreaInsets(
|
|
50
|
-
top =
|
|
51
|
-
right =
|
|
52
|
-
bottom =
|
|
53
|
-
left =
|
|
43
|
+
top = toCssPx(max(systemBars.top, cutoutInsets.top), density),
|
|
44
|
+
right = toCssPx(max(systemBars.right, cutoutInsets.right), density),
|
|
45
|
+
bottom = toCssPx(max(systemBars.bottom, cutoutInsets.bottom), density),
|
|
46
|
+
left = toCssPx(max(systemBars.left, cutoutInsets.left), density)
|
|
54
47
|
)
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
fun getSafeAreaInsets(window: Window, rootView: View, edgeToEdgeEnabled: Boolean): SafeAreaInsets {
|
|
58
51
|
val windowInsets =
|
|
59
52
|
ViewCompat.getRootWindowInsets(window.decorView) ?: ViewCompat.getRootWindowInsets(rootView)
|
|
60
|
-
return fromWindowInsets(windowInsets, edgeToEdgeEnabled)
|
|
53
|
+
return fromWindowInsets(windowInsets, edgeToEdgeEnabled, rootView.resources.displayMetrics.density)
|
|
61
54
|
}
|
|
55
|
+
|
|
56
|
+
private fun toCssPx(px: Int, density: Float): Int = max(0, ceil(px / density).toInt())
|
|
62
57
|
}
|
|
@@ -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
|
|
@@ -41,22 +45,40 @@ class SafeAreaUtilsTest {
|
|
|
41
45
|
|
|
42
46
|
@Test
|
|
43
47
|
fun `fromWindowInsets returns ZERO when insets is null`() {
|
|
44
|
-
val result = SafeAreaUtils.fromWindowInsets(null, edgeToEdgeEnabled = true)
|
|
48
|
+
val result = SafeAreaUtils.fromWindowInsets(null, edgeToEdgeEnabled = true, density = 1f)
|
|
45
49
|
assertEquals(SafeAreaInsets.ZERO, result)
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
// ============================================================
|
|
49
|
-
//
|
|
53
|
+
// getSafeAreaInsets passthrough
|
|
50
54
|
// ============================================================
|
|
51
55
|
|
|
52
56
|
@Test
|
|
53
|
-
fun `
|
|
57
|
+
fun `getSafeAreaInsets returns ZERO when the window has no root insets yet`() {
|
|
58
|
+
// Under the mockable android.jar Build.VERSION.SDK_INT is 0, so
|
|
59
|
+
// ViewCompat.getRootWindowInsets returns null for both views (no
|
|
60
|
+
// static mocking needed) and the passthrough delegates a null.
|
|
61
|
+
val resources = mock<Resources> { on { getDisplayMetrics() } doReturn DisplayMetrics() }
|
|
62
|
+
val rootView = mock<View> { on { getResources() } doReturn resources }
|
|
63
|
+
val window = mock<Window> { on { getDecorView() } doReturn mock<View>() }
|
|
64
|
+
|
|
65
|
+
val result = SafeAreaUtils.getSafeAreaInsets(window, rootView, edgeToEdgeEnabled = true)
|
|
66
|
+
|
|
67
|
+
assertEquals(SafeAreaInsets.ZERO, result)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ============================================================
|
|
71
|
+
// edge-to-edge disabled -- always ZERO (system already insets the WebView)
|
|
72
|
+
// ============================================================
|
|
73
|
+
|
|
74
|
+
@Test
|
|
75
|
+
fun `fromWindowInsets with edge-to-edge disabled returns ZERO`() {
|
|
54
76
|
val systemBars = Insets.of(10, 20, 30, 40)
|
|
55
77
|
val windowInsets = windowInsetsCompatWith(systemBars)
|
|
56
78
|
|
|
57
|
-
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false)
|
|
79
|
+
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false, density = 1f)
|
|
58
80
|
|
|
59
|
-
assertEquals(SafeAreaInsets
|
|
81
|
+
assertEquals(SafeAreaInsets.ZERO, result)
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
@Test
|
|
@@ -65,9 +87,9 @@ class SafeAreaUtilsTest {
|
|
|
65
87
|
val cutout = Insets.of(100, 100, 100, 100)
|
|
66
88
|
val windowInsets = windowInsetsCompatWith(systemBars, cutout)
|
|
67
89
|
|
|
68
|
-
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false)
|
|
90
|
+
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = false, density = 1f)
|
|
69
91
|
|
|
70
|
-
assertEquals(SafeAreaInsets
|
|
92
|
+
assertEquals(SafeAreaInsets.ZERO, result)
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
// ============================================================
|
|
@@ -80,7 +102,7 @@ class SafeAreaUtilsTest {
|
|
|
80
102
|
val cutout = Insets.of(0, 20, 60, 15)
|
|
81
103
|
val windowInsets = windowInsetsCompatWith(systemBars, cutout)
|
|
82
104
|
|
|
83
|
-
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true)
|
|
105
|
+
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 1f)
|
|
84
106
|
|
|
85
107
|
// left: max(10, 0)=10, top: max(50, 20)=50, right: max(30, 60)=60, bottom: max(5, 15)=15
|
|
86
108
|
assertEquals(SafeAreaInsets(top = 50, right = 60, bottom = 15, left = 10), result)
|
|
@@ -91,11 +113,21 @@ class SafeAreaUtilsTest {
|
|
|
91
113
|
val systemBars = Insets.of(10, 20, 30, 40)
|
|
92
114
|
val windowInsets = windowInsetsCompatWith(systemBars, Insets.NONE)
|
|
93
115
|
|
|
94
|
-
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true)
|
|
116
|
+
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 1f)
|
|
95
117
|
|
|
96
118
|
assertEquals(SafeAreaInsets(top = 20, right = 30, bottom = 40, left = 10), result)
|
|
97
119
|
}
|
|
98
120
|
|
|
121
|
+
@Test
|
|
122
|
+
fun `fromWindowInsets with edge-to-edge enabled converts physical px to CSS px by density`() {
|
|
123
|
+
val systemBars = Insets.of(0, 66, 0, 0)
|
|
124
|
+
val windowInsets = windowInsetsCompatWith(systemBars)
|
|
125
|
+
|
|
126
|
+
val result = SafeAreaUtils.fromWindowInsets(windowInsets, edgeToEdgeEnabled = true, density = 2.75f)
|
|
127
|
+
|
|
128
|
+
assertEquals(SafeAreaInsets(top = 24, right = 0, bottom = 0, left = 0), result)
|
|
129
|
+
}
|
|
130
|
+
|
|
99
131
|
// ============================================================
|
|
100
132
|
// SafeAreaInsets.toMap()
|
|
101
133
|
// ============================================================
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.useAI = useAI;
|
|
7
|
+
var _aiProviders = require("./aiProviders.js");
|
|
7
8
|
/* global __CATALYST_PACKAGES__ */
|
|
8
9
|
|
|
9
10
|
function getBrowserConfig() {
|
|
@@ -16,12 +17,10 @@ function getBrowserConfig() {
|
|
|
16
17
|
return null;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
|
-
function resolveMode(provider) {
|
|
20
|
-
if (provider === "transformers") return "local";
|
|
21
|
-
if (provider === "native") return "native";
|
|
22
|
-
return "cloud";
|
|
23
|
-
}
|
|
24
20
|
function isNativeAIAvailable() {
|
|
21
|
+
// catalyst-ai knows both platforms (Android's NativeBridge, iOS's injected plugin manifest).
|
|
22
|
+
// Older catalyst-ai versions don't export it, so fall back to the Android-only check.
|
|
23
|
+
if (_pkg && typeof _pkg.isNativeAIAvailable === "function") return _pkg.isNativeAIAvailable();
|
|
25
24
|
const nb = typeof window !== "undefined" && window.NativeBridge;
|
|
26
25
|
return nb && typeof nb.isAIAvailable === "function" && nb.isAIAvailable();
|
|
27
26
|
}
|
|
@@ -43,7 +42,8 @@ const _pkg = typeof window !== "undefined" && (typeof __CATALYST_PACKAGES__ ===
|
|
|
43
42
|
})() : null;
|
|
44
43
|
|
|
45
44
|
// provider: "transformers" → useWebAI (catalyst-ai)
|
|
46
|
-
// "native"
|
|
45
|
+
// "native" | "litert" | "foundation-models"
|
|
46
|
+
// → useNativeAI (catalyst-ai, falls back to useCloudAI if bridge unavailable)
|
|
47
47
|
// anything else → useCloudAI (catalyst-ai, default)
|
|
48
48
|
// The shape of this hook is owned by the catalyst-ai package and sits outside
|
|
49
49
|
// the native hook contract entirely, so its options and result stay loose here.
|
|
@@ -53,12 +53,13 @@ function useAI(options = {}) {
|
|
|
53
53
|
} = options;
|
|
54
54
|
const config = getBrowserConfig();
|
|
55
55
|
const resolvedProvider = provider || config?.provider;
|
|
56
|
-
const mode = resolveMode(resolvedProvider);
|
|
56
|
+
const mode = (0, _aiProviders.resolveMode)(resolvedProvider);
|
|
57
57
|
const cloudResult = _pkg ? _pkg.useCloudAI(options) : emptyHook();
|
|
58
58
|
const webResult = _pkg ? _pkg.useWebAI(options) : emptyHook();
|
|
59
59
|
const nativeResult = _pkg ? _pkg.useNativeAI({
|
|
60
60
|
...options,
|
|
61
|
-
enabled: mode === "native"
|
|
61
|
+
enabled: mode === "native",
|
|
62
|
+
engine: (0, _aiProviders.resolveNativeEngine)(resolvedProvider, options.engine, config)
|
|
62
63
|
}) : emptyHook();
|
|
63
64
|
if (typeof window === "undefined") return emptyHook();
|
|
64
65
|
if (!_pkg) {
|