@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 +2 -0
- package/package.json +1 -1
- package/templates/android/README.md +5 -0
- package/templates/android/app/build.gradle.kts +2 -0
- package/templates/android/app/src/main/java/dev/angularlynx/shell/MainActivity.kt +47 -1
- package/templates/android/app/src/main/java/dev/angularlynx/shell/WebAuthModule.kt +77 -0
- package/templates/ios/App/LynxViewController.swift +4 -1
- package/templates/ios/App/WebAuthModule.swift +95 -0
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
|
@@ -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 =
|
|
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
|
+
}
|