@blotch/angular-lynx-native 22.2.5 → 22.2.6

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/README.md CHANGED
@@ -19,6 +19,8 @@ Both are one full-screen `LynxView` running `main.lynx.bundle`; Angular Router d
19
19
 
20
20
  Both send the same global props, which the runtime's services read: `platform`, `theme` (again whenever the phone switches between light and dark), `appLocale` (the phone's language as a BCP 47 tag), `isNotchScreen`, the safe-area insets `safeAreaTop`, `safeAreaBottom`, `safeAreaLeft` and `safeAreaRight`, `osVersion` (which Lynx leaves out of the main thread's `SystemInfo`), and the accessibility settings `reduceMotion`, `reduceTransparency` (iOS only) and `increaseContrast`, again whenever the user changes one. Android 12 and later also send `dynamicColors`, the wallpaper's Material 3 color roles in light and dark (`DynamicColors.kt`), again when the user changes their wallpaper or colors. Lynx Explorer sends `theme` and `isNotchScreen` too, but no `appLocale`, `osVersion` or accessibility settings. See [`templates/android/README.md`](templates/android/README.md) for the Android shell's details.
21
21
 
22
+ Both register the native modules the runtime's services call, which Lynx Explorer doesn't have: `AngularLynxWebAuth` (`WebAuthModule.swift`, `WebAuthModule.kt`) opens `LynxWebAuthSession`'s sign-in page, in `ASWebAuthenticationSession` on iOS and a Custom Tab on Android, and returns the URL it redirects to. Android also has `AngularLynxShell`, for the back button.
23
+
22
24
  Both link Lynx's XElement library, which holds `<input>`, `<textarea>`, `<overlay>`, `<svg>`, `<blur-view>`, `<viewpager>`, `<refresh>`, the `<scroll-coordinator>` family, `<webview>` and `<video>`. Lynx's core doesn't include these, and a host that doesn't register them renders nothing for those tags (on Android, nothing after them either). XElement's AnimaX and Markdown elements are left out: the runtime doesn't support them. On iOS, `<blur-view>` is XElement's with more of UIKit in it (`App/BlurView.swift`): a `blur-material` attribute for UIKit's adaptive blur materials, which the glass components draw their design before iOS 26 on (Lynx's blur-view has only the styles from before iOS 13); Liquid Glass that materializes, dissolves and eases between effects inside a `glass-container`, where Lynx's sets its effect at once; and a `glass-effect-id` attribute that pairs two glass shapes across a change so one morphs into the other (`App/GlassTransitions.swift`).
23
25
 
24
26
  | | iOS | Android |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotch/angular-lynx-native",
3
- "version": "22.2.5",
3
+ "version": "22.2.6",
4
4
  "description": "Builds AngularLynx apps into iOS and Android binaries",
5
5
  "homepage": "https://github.com/Blotch-Smart-Frames/AngularLynx#readme",
6
6
  "bugs": {
@@ -34,6 +34,10 @@ Build commands and outputs (no flavors, so the names are fixed):
34
34
 
35
35
  Back is off by default, so it leaves the app like any root screen. The runtime turns it on while the Router has history by calling `NativeModules.AngularLynxShell.setBackHandlerEnabled(true)`, and off again on the first screen (`LynxBackButton` in `packages/runtime/src/lib/router/lynx-back-button.ts`, which `provideRouter()` starts; the call goes through the background thread, where `NativeModules` lives). While it's on, a back press sends the global event `angularLynxBack` instead, which the runtime turns into `Location.back()`. The app has to say beforehand because Android decides synchronously, at the start of the gesture, whether an app handles back (predictive back animates differently). Layer 6's `native/back-button.yaml` checks both ends.
36
36
 
37
+ ## Web sign-in
38
+
39
+ `NativeModules.AngularLynxWebAuth.start(request, callback)` (`WebAuthModule.kt`, behind the runtime's `LynxWebAuthSession`) opens `request.url` in a Custom Tab (`androidx.browser`). The page's redirect to `request.callbackScheme` comes back as a deep link: the activity is `singleTask`, so the intent reaches the open one in `onNewIntent`, which hands the URL to the callback, and starting it closes the Custom Tab above it. That's why the scheme has to be in the app's `deepLinkSchemes`. Android says nothing when a Custom Tab is closed, so the activity resuming with a session still open, after having gone behind the tab, means the person canceled. iOS's `WebAuthModule.swift` reports the same results through `ASWebAuthenticationSession`.
40
+
37
41
  ## Global props
38
42
 
39
43
  `MainActivity.globalProps()` sends what the runtime reads and Lynx has no API for (see the package README), again on every configuration change and accessibility setting change.
@@ -47,6 +51,7 @@ Back is off by default, so it leaves the app like any root screen. The runtime t
47
51
  Pinned in `app/build.gradle.kts`, `build.gradle.kts` and `gradle/wrapper/gradle-wrapper.properties`:
48
52
 
49
53
  - Lynx 4.1.0 and PrimJS 4.1.1 (Maven Central), Fresco 2.3.0 (the version Lynx's image service is built against), OkHttp 4.12.
54
+ - `androidx.browser` 1.8.0, for the Custom Tabs `WebAuthModule` opens sign-in pages in.
50
55
  - Lynx's XElement 4.1.0 (`<input>`, `<textarea>`, `<overlay>`, `<svg>`, `<blur-view>`, `<viewpager>`, `<refresh>`, the `<scroll-coordinator>` family, `<webview>`, `<video>`), without its AnimaX and Markdown modules, which the runtime doesn't support. `MainActivity` registers the elements with `XElementBehaviors`. XElement depends on appcompat and material 1.0.0, whose vectordrawable libraries share a namespace that AGP 9 rejects, so appcompat 1.7.1 and material 1.12.0 are pinned.
51
56
  - AGP 9.4.1 with its built-in Kotlin, Gradle 9.6.1 (AGP 9.4's minimum), JDK 17 or newer.
52
57
  - `compileSdk` and `targetSdk` 36, `minSdk` 24. Google Play raises its target API requirement every August; bump `targetSdk` with it.
@@ -118,6 +118,8 @@ android {
118
118
  dependencies {
119
119
  implementation("androidx.activity:activity-ktx:1.10.1")
120
120
  implementation("androidx.core:core-ktx:1.16.0")
121
+ // Custom Tabs, which WebAuthModule opens sign-in pages in.
122
+ implementation("androidx.browser:browser:1.8.0")
121
123
 
122
124
  // The Lynx engine, its JavaScript runtime (PrimJS) and its JS SDK.
123
125
  implementation("org.lynxsdk.lynx:lynx:$lynxVersion")
@@ -1,9 +1,12 @@
1
1
  package dev.angularlynx.shell
2
2
 
3
3
  import android.app.UiModeManager
4
+ import android.content.ActivityNotFoundException
5
+ import android.content.Intent
4
6
  import android.content.res.Configuration
5
7
  import android.database.ContentObserver
6
8
  import android.graphics.drawable.ColorDrawable
9
+ import android.net.Uri
7
10
  import android.os.Build
8
11
  import android.os.Bundle
9
12
  import android.os.Handler
@@ -15,6 +18,7 @@ import android.widget.FrameLayout
15
18
  import androidx.activity.ComponentActivity
16
19
  import androidx.activity.OnBackPressedCallback
17
20
  import androidx.activity.enableEdgeToEdge
21
+ import androidx.browser.customtabs.CustomTabsIntent
18
22
  import androidx.core.graphics.Insets
19
23
  import androidx.core.os.ConfigurationCompat
20
24
  import androidx.core.view.ViewCompat
@@ -31,7 +35,7 @@ import com.lynx.xelement.XElementBehaviors
31
35
  * The whole app: one full-screen LynxView running the Angular app. Angular
32
36
  * Router navigates inside it, so there is no native navigation stack.
33
37
  */
34
- class MainActivity : ComponentActivity(), ShellModule.Host {
38
+ class MainActivity : ComponentActivity(), ShellModule.Host, WebAuthModule.Host {
35
39
  private lateinit var lynxView: LynxView
36
40
 
37
41
  // The system bar and cutout insets, and whether the screen has a cutout,
@@ -57,6 +61,13 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
57
61
  }
58
62
  }
59
63
 
64
+ // The open sign-in (see WebAuthModule): the scheme its page returns to,
65
+ // who to tell, and whether the app has gone behind the Custom Tab yet.
66
+ private class WebAuthSession(val scheme: String, val done: (WebAuthModule.Result) -> Unit) {
67
+ var opened = false
68
+ }
69
+ private var webAuthSession: WebAuthSession? = null
70
+
60
71
  override fun onCreate(savedInstanceState: Bundle?) {
61
72
  // Android 15+ always draws apps edge to edge; opting in on older
62
73
  // versions too keeps the layout the same everywhere.
@@ -70,6 +81,7 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
70
81
  setEnableGenericResourceFetcher(LynxBooleanOption.TRUE)
71
82
  setGenericResourceFetcher(AssetGenericResourceFetcher(this@MainActivity))
72
83
  registerModule(ShellModule.NAME, ShellModule::class.java, this@MainActivity)
84
+ registerModule(WebAuthModule.NAME, WebAuthModule::class.java, this@MainActivity)
73
85
  // Lynx's core has no <input>, <overlay>, <svg>, <blur-view> etc.;
74
86
  // these come from XElement (see app/build.gradle.kts). Lynx
75
87
  // Explorer registers them the same way.
@@ -136,6 +148,33 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
136
148
  backHandler.isEnabled = enabled
137
149
  }
138
150
 
151
+ override fun startWebAuthSession(url: Uri, callbackScheme: String, done: (WebAuthModule.Result) -> Unit) {
152
+ if (webAuthSession != null) {
153
+ done(WebAuthModule.Result.Failed("A sign-in is already open."))
154
+ return
155
+ }
156
+ val session = WebAuthSession(callbackScheme.lowercase(), done)
157
+ webAuthSession = session
158
+ try {
159
+ CustomTabsIntent.Builder().build().launchUrl(this, url)
160
+ } catch (_: ActivityNotFoundException) {
161
+ webAuthSession = null
162
+ done(WebAuthModule.Result.Failed("No browser can open the sign-in page."))
163
+ }
164
+ }
165
+
166
+ // The page's redirect to the callback scheme. The activity is singleTask,
167
+ // so the deep link comes back to this one, which closes the Custom Tab
168
+ // above it. It arrives before onResume, which then finds no session open.
169
+ override fun onNewIntent(intent: Intent) {
170
+ super.onNewIntent(intent)
171
+ val url = intent.data ?: return
172
+ val session = webAuthSession ?: return
173
+ if (url.scheme?.lowercase() != session.scheme) return
174
+ webAuthSession = null
175
+ session.done(WebAuthModule.Result.Returned(url.toString()))
176
+ }
177
+
139
178
  // The manifest's configChanges keeps the activity, and the running app,
140
179
  // through a dark mode switch, so the new theme is sent from here. A
141
180
  // language change still restarts the activity, which sends the new
@@ -212,10 +251,17 @@ class MainActivity : ComponentActivity(), ShellModule.Host {
212
251
  override fun onResume() {
213
252
  super.onResume()
214
253
  lynxView.onEnterForeground()
254
+ // Back from a Custom Tab without its redirect: the person closed it.
255
+ // Android says nothing else when a Custom Tab closes.
256
+ webAuthSession?.takeIf { it.opened }?.let {
257
+ webAuthSession = null
258
+ it.done(WebAuthModule.Result.Canceled)
259
+ }
215
260
  }
216
261
 
217
262
  override fun onPause() {
218
263
  lynxView.onEnterBackground()
264
+ webAuthSession?.opened = true
219
265
  super.onPause()
220
266
  }
221
267
 
@@ -0,0 +1,77 @@
1
+ package dev.angularlynx.shell
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import com.lynx.jsbridge.LynxMethod
8
+ import com.lynx.jsbridge.LynxModule
9
+ import com.lynx.react.bridge.Callback
10
+ import com.lynx.react.bridge.JavaOnlyMap
11
+ import com.lynx.react.bridge.ReadableMap
12
+
13
+ /**
14
+ * The native module behind the runtime's `LynxWebAuthSession`, which calls it
15
+ * as `NativeModules.AngularLynxWebAuth` (from the background thread, where
16
+ * NativeModules lives).
17
+ *
18
+ * Lynx has no popups or redirects, so a sign-in page can't run inside the
19
+ * app. The activity opens it in a Custom Tab instead, and the page's redirect
20
+ * to the callback scheme comes back to it as a deep link, which is why the
21
+ * scheme has to be one of the app's `deepLinkSchemes`.
22
+ */
23
+ class WebAuthModule(context: Context, param: Any?) : LynxModule(context, param) {
24
+ private val host = param as Host
25
+ private val main = Handler(Looper.getMainLooper())
26
+
27
+ interface Host {
28
+ /**
29
+ * Opens [url] and calls [done] once with the URL the page redirected
30
+ * to on [callbackScheme], or with why it didn't.
31
+ */
32
+ fun startWebAuthSession(url: Uri, callbackScheme: String, done: (Result) -> Unit)
33
+ }
34
+
35
+ /** How a session ended: the URL the page returned to, or why it didn't. */
36
+ sealed interface Result {
37
+ data class Returned(val url: String) : Result
38
+ data object Canceled : Result
39
+ data class Failed(val message: String) : Result
40
+ }
41
+
42
+ /**
43
+ * Calls back once with `{ url }` or `{ error, message }`, where `error` is
44
+ * `canceled` or `failed`, as the iOS module does.
45
+ */
46
+ @LynxMethod
47
+ fun start(request: ReadableMap, callback: Callback) {
48
+ val url = request.getString("url", "")
49
+ val scheme = request.getString("callbackScheme", "")
50
+ if (url.isEmpty() || scheme.isEmpty()) {
51
+ callback.invoke(failed("The sign-in needs a URL and a callback scheme."))
52
+ return
53
+ }
54
+ // Lynx calls modules off the main thread, and the activity starts the
55
+ // Custom Tab.
56
+ main.post {
57
+ host.startWebAuthSession(Uri.parse(url), scheme) { result ->
58
+ callback.invoke(
59
+ when (result) {
60
+ is Result.Returned -> JavaOnlyMap.from(mapOf("url" to result.url))
61
+ Result.Canceled -> JavaOnlyMap.from(
62
+ mapOf("error" to "canceled", "message" to "The sign-in was canceled."),
63
+ )
64
+ is Result.Failed -> failed(result.message)
65
+ },
66
+ )
67
+ }
68
+ }
69
+ }
70
+
71
+ private fun failed(message: String) =
72
+ JavaOnlyMap.from(mapOf("error" to "failed", "message" to message))
73
+
74
+ companion object {
75
+ const val NAME = "AngularLynxWebAuth"
76
+ }
77
+ }
@@ -13,8 +13,11 @@ final class LynxViewController: UIViewController {
13
13
 
14
14
  let fetcher = BundleResourceFetcher.shared
15
15
  let screenSize = UIScreen.main.bounds.size
16
+ // The shell's native modules, which the runtime's services call.
17
+ let config = LynxConfig(provider: LynxEnv.sharedInstance().config.templateProvider)
18
+ config.register(WebAuthModule.self)
16
19
  let lynxView = LynxView { builder in
17
- builder.config = LynxConfig(provider: LynxEnv.sharedInstance().config.templateProvider)
20
+ builder.config = config
18
21
  builder.screenSize = screenSize
19
22
  builder.fontScale = 1.0
20
23
  builder.enableGenericResourceFetcher = .true
@@ -0,0 +1,95 @@
1
+ import AuthenticationServices
2
+ import Lynx
3
+ import UIKit
4
+
5
+ /// The native module behind the runtime's `LynxWebAuthSession`, which calls it
6
+ /// as `NativeModules.AngularLynxWebAuth` (from the background thread, where
7
+ /// NativeModules lives).
8
+ ///
9
+ /// Lynx has no popups or redirects, so a sign-in page can't run inside the
10
+ /// app. ASWebAuthenticationSession opens it in Safari's sign-in sheet instead,
11
+ /// asking the person first, and ends when the page redirects to the callback
12
+ /// scheme. The scheme needn't be registered in Info.plist: the session catches
13
+ /// the redirect itself, so no other app can.
14
+ final class WebAuthModule: NSObject, LynxModule, ASWebAuthenticationPresentationContextProviding {
15
+ static var name: String { "AngularLynxWebAuth" }
16
+
17
+ static var methodLookup: [String: String] {
18
+ ["start": NSStringFromSelector(#selector(start(_:callback:)))]
19
+ }
20
+
21
+ /// The open session. Held until it ends: ASWebAuthenticationSession closes
22
+ /// its sheet when released.
23
+ private var session: ASWebAuthenticationSession?
24
+
25
+ override init() {
26
+ super.init()
27
+ }
28
+
29
+ // Swift can't import an optional initializer as optional, so Lynx's
30
+ // initWithParam: is required here. Registered without a param, the module
31
+ // ignores it.
32
+ init(param: Any) {
33
+ super.init()
34
+ }
35
+
36
+ /// Opens `request.url` and calls back once with `{ url }`, the URL the page
37
+ /// redirected to, or `{ error, message }`, where `error` is `canceled` or
38
+ /// `failed`.
39
+ @objc func start(_ request: [String: Any], callback: @escaping LynxCallbackBlock) {
40
+ // Lynx calls modules off the main thread, and the session presents UI.
41
+ DispatchQueue.main.async {
42
+ self.open(request, callback)
43
+ }
44
+ }
45
+
46
+ private func open(_ request: [String: Any], _ callback: @escaping LynxCallbackBlock) {
47
+ let fail = { (message: String) in callback(["error": "failed", "message": message]) }
48
+ guard session == nil else {
49
+ fail("A sign-in is already open.")
50
+ return
51
+ }
52
+ guard let address = request["url"] as? String, let url = URL(string: address),
53
+ let scheme = request["callbackScheme"] as? String, !scheme.isEmpty
54
+ else {
55
+ fail("The sign-in needs a URL and a callback scheme.")
56
+ return
57
+ }
58
+
59
+ let completion: ASWebAuthenticationSession.CompletionHandler = { [weak self] url, error in
60
+ self?.session = nil
61
+ if let url {
62
+ callback(["url": url.absoluteString])
63
+ } else if let error = error as? ASWebAuthenticationSessionError,
64
+ error.code == .canceledLogin
65
+ {
66
+ callback(["error": "canceled", "message": "The sign-in was canceled."])
67
+ } else {
68
+ fail(error?.localizedDescription ?? "The sign-in page could not be opened.")
69
+ }
70
+ }
71
+ let session: ASWebAuthenticationSession
72
+ if #available(iOS 17.4, *) {
73
+ session = ASWebAuthenticationSession(
74
+ url: url, callback: .customScheme(scheme), completionHandler: completion)
75
+ } else {
76
+ session = ASWebAuthenticationSession(
77
+ url: url, callbackURLScheme: scheme, completionHandler: completion)
78
+ }
79
+ session.presentationContextProvider = self
80
+ session.prefersEphemeralWebBrowserSession = (request["ephemeral"] as? Bool) ?? false
81
+ self.session = session
82
+ if !session.start() {
83
+ self.session = nil
84
+ fail("The sign-in page could not be opened.")
85
+ }
86
+ }
87
+
88
+ /// The sheet opens over the app's window.
89
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
90
+ UIApplication.shared.connectedScenes
91
+ .compactMap { $0 as? UIWindowScene }
92
+ .flatMap(\.windows)
93
+ .first(where: \.isKeyWindow) ?? ASPresentationAnchor()
94
+ }
95
+ }