feat: UxUrl — native URL / phone / email detection + tap launcher
Sync FFI from Dart into platform detectors: - iOS / macOS: NSDataDetector(.link | .phoneNumber) + a tight bare-domain pass that requires `/` or `?` (so `etc.` / `v1.2.3` don't false-positive while `example.com/path` does match). NFKD-fold the phone capture so full-width / Arabic-Indic digits collapse to ASCII; stop the digit run at the first letter so `+1 555 1234 ext.99` doesn't fuse the extension. - Android: JNI into android.util.Patterns (WEB_URL / EMAIL_ADDRESS / PHONE) via a cached JavaVM, std::call_once for init, full per-call ExceptionCheck coverage. UTF-16→UTF-8 conversion is hand-rolled to dodge the Modified-UTF-8 / CESU-8 incompatibility with Dart's utf8.decode. `UxUrl.launch(url)` is the matching tap action. Channel side dispatches via UIApplication / NSWorkspace / Intent.ACTION_VIEW. Dart-side gates the URL against a scheme allowlist (http, https, mailto, tel, sms, banlu, tg), rejects bidi-override controls (U+202A..E / U+2066..9) to prevent visual spoofs, and blocks USSD / MMI tel: codes containing `*` or `#`. Library/native cleanup along the way: - Renamed libux_keyboard.so to libux.so (also covers sensor + url). - Collapsed three near-identical FFI loader stanzas across keyboard / sensor / url into a shared lib/src/_ffi.dart with `uxLib` + typed `uxLookupX` helpers.
This commit is contained in:
58
lib/src/_ffi.dart
Normal file
58
lib/src/_ffi.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
DynamicLibrary? _open() {
|
||||
if (kIsWeb) return null;
|
||||
try {
|
||||
if (Platform.isIOS || Platform.isMacOS) return DynamicLibrary.process();
|
||||
if (Platform.isAndroid) return DynamicLibrary.open('libux.so');
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `null` on web, unsupported platforms, or load failure.
|
||||
final DynamicLibrary? uxLib = _open();
|
||||
|
||||
double Function()? uxLookupDouble(String name) {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<Double Function()>>(name).asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function()? uxLookupInt32(String name) {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<Int32 Function()>>(name).asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Function()? uxLookupVoid(String name) {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<Void Function()>>(name).asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Function(double)? uxLookupVoidDouble(String name) {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<Void Function(Double)>>(name).asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,24 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/_ffi.dart';
|
||||
import 'package:ux/src/log.dart';
|
||||
|
||||
final _kbLog = Log.tag('KB');
|
||||
|
||||
DynamicLibrary? _initLib() {
|
||||
if (Platform.isIOS) return DynamicLibrary.process();
|
||||
if (Platform.isAndroid) return DynamicLibrary.open('libux_keyboard.so');
|
||||
return null;
|
||||
}
|
||||
final _uxKeyboardHeight = uxLookupDouble('ux_keyboard_height');
|
||||
final _uxSystemHeight = uxLookupDouble('ux_system_keyboard_height');
|
||||
final _uxIsTracking = uxLookupInt32('ux_is_tracking');
|
||||
final _uxAnimTarget = uxLookupDouble('ux_keyboard_anim_target');
|
||||
final _uxAnimDuration = uxLookupDouble('ux_keyboard_anim_duration');
|
||||
final _uxAnimGen = uxLookupInt32('ux_keyboard_anim_gen');
|
||||
|
||||
final DynamicLibrary? _lib = _initLib();
|
||||
|
||||
double Function()? _lookupDouble(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Double Function()>>(name).asFunction<double Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function()? _lookupInt32(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Int32 Function()>>(name).asFunction<int Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void Function()? _lookupVoid(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Void Function()>>(name).asFunction<void Function()>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _uxKeyboardHeight = _lookupDouble('ux_keyboard_height');
|
||||
final _uxSystemHeight = _lookupDouble('ux_system_keyboard_height');
|
||||
final _uxIsTracking = _lookupInt32('ux_is_tracking');
|
||||
final _uxAnimTarget = _lookupDouble('ux_keyboard_anim_target');
|
||||
final _uxAnimDuration = _lookupDouble('ux_keyboard_anim_duration');
|
||||
final _uxAnimGen = _lookupInt32('ux_keyboard_anim_gen');
|
||||
|
||||
void Function(double)? _lookupEnableInteractiveDismiss() {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!
|
||||
.lookup<NativeFunction<Void Function(Double)>>('ux_enable_interactive_dismiss')
|
||||
.asFunction<void Function(double)>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _uxEnableInteractiveDismiss = _lookupEnableInteractiveDismiss();
|
||||
final _uxDisableInteractiveDismiss = _lookupVoid('ux_disable_interactive_dismiss');
|
||||
final _uxEnableInteractiveDismiss = uxLookupVoidDouble('ux_enable_interactive_dismiss');
|
||||
final _uxDisableInteractiveDismiss = uxLookupVoid('ux_disable_interactive_dismiss');
|
||||
|
||||
/// iOS keyboard animation curve — sampled from native CADisplayLink.
|
||||
/// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles.
|
||||
@@ -148,7 +103,7 @@ class UxKeyboard with ChangeNotifier {
|
||||
if (Platform.isAndroid) {
|
||||
_channel.setMethodCallHandler(_onMethodCall);
|
||||
}
|
||||
if (_lib == null) return;
|
||||
if (uxLib == null) return;
|
||||
SchedulerBinding.instance.addPersistentFrameCallback(_onFrame);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
DynamicLibrary? _initLib() {
|
||||
if (Platform.isIOS) return DynamicLibrary.process();
|
||||
if (Platform.isAndroid) return DynamicLibrary.open('libux_keyboard.so');
|
||||
return null;
|
||||
}
|
||||
import 'package:ux/src/_ffi.dart';
|
||||
|
||||
final DynamicLibrary? _lib = _initLib();
|
||||
|
||||
int Function()? _lookupInt32(String name) {
|
||||
if (_lib == null) return null;
|
||||
try {
|
||||
return _lib!.lookup<NativeFunction<Int32 Function()>>(name).asFunction<int Function()>();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _uxDeviceOrientation = _lookupInt32('ux_device_orientation');
|
||||
final _uxDeviceOrientation = uxLookupInt32('ux_device_orientation');
|
||||
|
||||
class UxSensor {
|
||||
UxSensor._();
|
||||
|
||||
245
lib/src/url.dart
Normal file
245
lib/src/url.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/_ffi.dart';
|
||||
|
||||
/// Native URL / phone / email detection plus an OS-handler launcher.
|
||||
class UxUrl {
|
||||
UxUrl._();
|
||||
|
||||
static const _channel = MethodChannel('ux/url');
|
||||
|
||||
/// Returns ranges of detected URLs / emails / phone numbers in [text],
|
||||
/// sorted by [UrlMatch.start] ascending and non-overlapping. Offsets
|
||||
/// are UTF-16 code units. Empty / no-sigil inputs and missing native
|
||||
/// symbols resolve to `const []`.
|
||||
static List<UrlMatch> match(String text) {
|
||||
if (text.isEmpty) return const [];
|
||||
if (!_hasSigil(text)) return const [];
|
||||
final detect = _matchUrl;
|
||||
final free = _free;
|
||||
if (detect == null || free == null) return const [];
|
||||
|
||||
final inPtr = text.toNativeUtf16();
|
||||
final sizePtr = calloc<Int32>();
|
||||
Pointer<Uint8> out = nullptr;
|
||||
try {
|
||||
out = detect(inPtr.cast<Uint16>(), text.length, sizePtr);
|
||||
final size = sizePtr.value;
|
||||
if (out == nullptr || size <= 0) return const [];
|
||||
return _decode(out.asTypedList(size));
|
||||
} finally {
|
||||
calloc.free(inPtr);
|
||||
calloc.free(sizePtr);
|
||||
if (out != nullptr) free(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens [url] via the OS handler. Returns false on channel error or
|
||||
/// when no handler can resolve the scheme.
|
||||
///
|
||||
/// Scheme is gated against a fixed allowlist. Schemes like `intent://`,
|
||||
/// `file://`, `javascript:`, `data:`, or USSD-bearing `tel:*…#` are
|
||||
/// rejected before reaching the OS dispatcher — message text is
|
||||
/// sender-controlled and the OS will happily launch privileged or
|
||||
/// destructive URIs otherwise.
|
||||
static Future<bool> launch(String url) async {
|
||||
if (kIsWeb) return false;
|
||||
if (!(Platform.isIOS || Platform.isAndroid || Platform.isMacOS)) {
|
||||
return false;
|
||||
}
|
||||
if (!_isLaunchable(url)) return false;
|
||||
try {
|
||||
final ok = await _channel.invokeMethod<bool>('launch', {'url': url});
|
||||
return ok ?? false;
|
||||
} on PlatformException {
|
||||
return false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Set<String> _kLaunchSchemes = {
|
||||
'http', 'https',
|
||||
'mailto', 'tel', 'sms',
|
||||
'banlu', 'tg',
|
||||
};
|
||||
|
||||
bool _isLaunchable(String url) {
|
||||
final colon = url.indexOf(':');
|
||||
if (colon <= 0) return false;
|
||||
final scheme = url.substring(0, colon).toLowerCase();
|
||||
if (!_kLaunchSchemes.contains(scheme)) return false;
|
||||
// Bidi controls let a sender visually reverse the displayed URL while
|
||||
// the byte order — what the OS opens — points elsewhere. Reject any
|
||||
// URL containing them rather than guessing the user's intent.
|
||||
for (int i = 0; i < url.length; i++) {
|
||||
final c = url.codeUnitAt(i);
|
||||
if ((c >= 0x202A && c <= 0x202E) || (c >= 0x2066 && c <= 0x2069)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// tel: USSD / MMI codes (*06# style) can leak the IMEI on Android.
|
||||
if (scheme == 'tel') {
|
||||
final body = url.substring(colon + 1);
|
||||
if (body.contains('*') || body.contains('#')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// One detected span inside a source string.
|
||||
class UrlMatch {
|
||||
/// Creates a span with UTF-16 [start]/[end] offsets and a canonical [url].
|
||||
const UrlMatch({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.url,
|
||||
required this.kind,
|
||||
});
|
||||
|
||||
/// Inclusive UTF-16 code-unit offset.
|
||||
final int start;
|
||||
|
||||
/// Exclusive UTF-16 code-unit offset.
|
||||
final int end;
|
||||
|
||||
/// Canonical openable URL: `www.x.com` → `http://www.x.com`,
|
||||
/// phone → `tel:<digits>`, email → `mailto:<addr>`.
|
||||
final String url;
|
||||
|
||||
/// What kind of thing was detected.
|
||||
final UrlMatchKind kind;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UrlMatch &&
|
||||
start == other.start &&
|
||||
end == other.end &&
|
||||
kind == other.kind &&
|
||||
url == other.url;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(start, end, kind, url);
|
||||
|
||||
@override
|
||||
String toString() => 'UrlMatch($kind, [$start,$end), $url)';
|
||||
}
|
||||
|
||||
/// Categorizes a [UrlMatch] for tap routing and styling.
|
||||
enum UrlMatchKind {
|
||||
/// http(s) and custom-scheme URLs.
|
||||
web,
|
||||
|
||||
/// Email address → `mailto:` URL.
|
||||
email,
|
||||
|
||||
/// Phone number → `tel:` URL.
|
||||
phone;
|
||||
|
||||
/// Stable byte tag shared across the FFI boundary and the on-disk codec.
|
||||
int get wireByte => index;
|
||||
|
||||
/// Lenient decode: unknown bytes fall back to [web] so a DB row written
|
||||
/// by a future build (e.g. a new kind we haven't added yet) still
|
||||
/// renders as a tappable link after a downgrade.
|
||||
static UrlMatchKind fromByte(int b) {
|
||||
if (b >= 0 && b < UrlMatchKind.values.length) {
|
||||
return UrlMatchKind.values[b];
|
||||
}
|
||||
return UrlMatchKind.web;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI binding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Native signature:
|
||||
/// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
|
||||
///
|
||||
/// Returned buffer is malloc'd by native (caller frees via [ux_free]):
|
||||
/// u32 count
|
||||
/// count * { i32 start, i32 end, u32 kind, u32 url_len, u8[url_len] url_utf8 }
|
||||
typedef _MatchUrlNative = Pointer<Uint8> Function(
|
||||
Pointer<Uint16> utf16, Int32 len, Pointer<Int32> outSize);
|
||||
typedef _MatchUrl = Pointer<Uint8> Function(
|
||||
Pointer<Uint16> utf16, int len, Pointer<Int32> outSize);
|
||||
|
||||
typedef _FreeNative = Void Function(Pointer<Uint8> buf);
|
||||
typedef _Free = void Function(Pointer<Uint8> buf);
|
||||
|
||||
_MatchUrl? _lookupMatchUrl() {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<_MatchUrlNative>>('ux_match_url').asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_Free? _lookupFree() {
|
||||
final lib = uxLib;
|
||||
if (lib == null) return null;
|
||||
try {
|
||||
return lib.lookup<NativeFunction<_FreeNative>>('ux_free').asFunction();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final _MatchUrl? _matchUrl = _lookupMatchUrl();
|
||||
final _Free? _free = _lookupFree();
|
||||
|
||||
bool _hasSigil(String text) {
|
||||
for (int i = 0; i < text.length; i++) {
|
||||
final c = text.codeUnitAt(i);
|
||||
if (c == 0x2E /* . */ ||
|
||||
c == 0x3A /* : */ ||
|
||||
c == 0x40 /* @ */ ||
|
||||
c == 0x2B /* + */) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hard cap on matches we'll surface per message — defends against a
|
||||
// sender crafting text designed to balloon a chat bubble's recognizer
|
||||
// count. 64 is well above any real chat density.
|
||||
const int _kMaxMatchesPerMessage = 64;
|
||||
|
||||
List<UrlMatch> _decode(Uint8List buf) {
|
||||
if (buf.length < 4) return const [];
|
||||
final view = ByteData.sublistView(buf);
|
||||
final count = view.getUint32(0, Endian.little);
|
||||
if (count == 0) return const [];
|
||||
final capped = count > _kMaxMatchesPerMessage ? _kMaxMatchesPerMessage : count;
|
||||
final out = <UrlMatch>[];
|
||||
int p = 4;
|
||||
for (int i = 0; i < capped; i++) {
|
||||
if (p + 16 > buf.length) break;
|
||||
final start = view.getInt32(p, Endian.little);
|
||||
final end = view.getInt32(p + 4, Endian.little);
|
||||
final kindByte = view.getUint32(p + 8, Endian.little);
|
||||
final urlLen = view.getUint32(p + 12, Endian.little);
|
||||
p += 16;
|
||||
if (p + urlLen > buf.length) break;
|
||||
final url = utf8.decode(Uint8List.sublistView(buf, p, p + urlLen));
|
||||
p += urlLen;
|
||||
out.add(UrlMatch(
|
||||
start: start,
|
||||
end: end,
|
||||
url: url,
|
||||
kind: UrlMatchKind.fromByte(kindByte),
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export 'src/gallery.dart';
|
||||
export 'src/keyboard.dart';
|
||||
export 'src/auto_map.dart';
|
||||
export 'src/scanner.dart';
|
||||
export 'src/url.dart';
|
||||
export 'src/sensor.dart';
|
||||
export 'src/functional.dart';
|
||||
export 'src/crash.dart';
|
||||
|
||||
Reference in New Issue
Block a user