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:
agra
2026-05-14 22:59:25 +03:00
parent 3d36f17edf
commit b4b5ee58a9
22 changed files with 1262 additions and 83 deletions

View File

@@ -1,8 +1,17 @@
cmake_minimum_required(VERSION 3.10)
project(ux_keyboard)
project(ux)
add_library(ux_keyboard SHARED keyboard_bridge.c sensor_bridge.c)
target_link_libraries(ux_keyboard log)
# C bridges (keyboard, sensor) + C++ shim (url_detect → android.util.Patterns via JNI).
add_library(ux SHARED
keyboard_bridge.c
sensor_bridge.c
url_detect.cpp)
set_target_properties(ux PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED ON)
target_link_libraries(ux log)
# 16KB page size support (required for Android 15+)
target_link_options(ux_keyboard PRIVATE "-Wl,-z,max-page-size=16384")
target_link_options(ux PRIVATE "-Wl,-z,max-page-size=16384")

View File

@@ -0,0 +1,348 @@
// Native data detection for UxUrl on Android. Synchronous, callable via dart:ffi.
//
// Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
// void ux_free(uint8_t* buf);
//
// Output buffer layout (little-endian):
// u32 count
// count * { i32 start, i32 end, u32 kind, u32 url_len, u8[url_len] url_utf8 }
// where kind ∈ { 0=web, 1=email, 2=phone }.
//
// Backed by `android.util.Patterns.WEB_URL` / `EMAIL_ADDRESS` / `PHONE` via
// JNI — so we ship the exact AOSP regexes that `Linkify` uses, kept in sync
// by the platform. No hand-rolled patterns to maintain.
#include <android/log.h>
#include <jni.h>
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <string>
#include <vector>
#define UX_LOG_TAG "UxUrl"
#define UX_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, UX_LOG_TAG, __VA_ARGS__)
namespace {
constexpr uint32_t kKindWeb = 0;
constexpr uint32_t kKindEmail = 1;
constexpr uint32_t kKindPhone = 2;
struct RawMatch {
int32_t start;
int32_t end;
uint32_t kind;
std::string url; // canonical, openable
};
// Cached from JNI_OnLoad / init_once.
JavaVM* g_vm = nullptr;
jobject g_web_url_pattern = nullptr; // global ref to Patterns.WEB_URL
jobject g_email_pattern = nullptr; // global ref to Patterns.EMAIL_ADDRESS
jobject g_phone_pattern = nullptr; // global ref to Patterns.PHONE
jmethodID g_matcher_method = nullptr; // Pattern.matcher(CharSequence)
jmethodID g_find_method = nullptr; // Matcher.find()
jmethodID g_start_method = nullptr; // Matcher.start()
jmethodID g_end_method = nullptr; // Matcher.end()
jmethodID g_group_method = nullptr; // Matcher.group()
std::once_flag g_init_once;
bool g_init_ok = false;
void clear_exception(JNIEnv* env) {
if (env->ExceptionCheck()) {
env->ExceptionDescribe();
env->ExceptionClear();
}
}
void init_jni_once(JNIEnv* env) {
jclass patternsLocal = env->FindClass("android/util/Patterns");
if (patternsLocal == nullptr || env->ExceptionCheck()) { clear_exception(env); return; }
jfieldID webField = env->GetStaticFieldID(patternsLocal, "WEB_URL", "Ljava/util/regex/Pattern;");
if (env->ExceptionCheck() || webField == nullptr) { clear_exception(env); env->DeleteLocalRef(patternsLocal); return; }
jfieldID emailField = env->GetStaticFieldID(patternsLocal, "EMAIL_ADDRESS", "Ljava/util/regex/Pattern;");
if (env->ExceptionCheck() || emailField == nullptr) { clear_exception(env); env->DeleteLocalRef(patternsLocal); return; }
jfieldID phoneField = env->GetStaticFieldID(patternsLocal, "PHONE", "Ljava/util/regex/Pattern;");
if (env->ExceptionCheck() || phoneField == nullptr) { clear_exception(env); env->DeleteLocalRef(patternsLocal); return; }
jobject webLocal = env->GetStaticObjectField(patternsLocal, webField);
if (env->ExceptionCheck() || webLocal == nullptr) { clear_exception(env); env->DeleteLocalRef(patternsLocal); return; }
jobject emailLocal = env->GetStaticObjectField(patternsLocal, emailField);
if (env->ExceptionCheck() || emailLocal == nullptr) { clear_exception(env); env->DeleteLocalRef(patternsLocal); env->DeleteLocalRef(webLocal); return; }
jobject phoneLocal = env->GetStaticObjectField(patternsLocal, phoneField);
if (env->ExceptionCheck() || phoneLocal == nullptr) {
clear_exception(env);
env->DeleteLocalRef(patternsLocal);
env->DeleteLocalRef(webLocal);
env->DeleteLocalRef(emailLocal);
return;
}
env->DeleteLocalRef(patternsLocal);
jclass patternLocal = env->FindClass("java/util/regex/Pattern");
if (env->ExceptionCheck() || patternLocal == nullptr) { clear_exception(env); return; }
jclass matcherLocal = env->FindClass("java/util/regex/Matcher");
if (env->ExceptionCheck() || matcherLocal == nullptr) { clear_exception(env); env->DeleteLocalRef(patternLocal); return; }
jmethodID matcherMethod = env->GetMethodID(patternLocal, "matcher", "(Ljava/lang/CharSequence;)Ljava/util/regex/Matcher;");
jmethodID findMethod = env->GetMethodID(matcherLocal, "find", "()Z");
jmethodID startMethod = env->GetMethodID(matcherLocal, "start", "()I");
jmethodID endMethod = env->GetMethodID(matcherLocal, "end", "()I");
jmethodID groupMethod = env->GetMethodID(matcherLocal, "group", "()Ljava/lang/String;");
env->DeleteLocalRef(patternLocal);
env->DeleteLocalRef(matcherLocal);
if (env->ExceptionCheck() || matcherMethod == nullptr || findMethod == nullptr ||
startMethod == nullptr || endMethod == nullptr || groupMethod == nullptr) {
clear_exception(env);
return;
}
g_web_url_pattern = env->NewGlobalRef(webLocal);
g_email_pattern = env->NewGlobalRef(emailLocal);
g_phone_pattern = env->NewGlobalRef(phoneLocal);
env->DeleteLocalRef(webLocal);
env->DeleteLocalRef(emailLocal);
env->DeleteLocalRef(phoneLocal);
g_matcher_method = matcherMethod;
g_find_method = findMethod;
g_start_method = startMethod;
g_end_method = endMethod;
g_group_method = groupMethod;
g_init_ok = true;
}
bool init_jni(JNIEnv* env) {
std::call_once(g_init_once, init_jni_once, env);
return g_init_ok;
}
// JVM strings hand us *modified* UTF-8 (CESU-8 for supplementary code points,
// NUL encoded as 0xC0 0x80) — Dart's `utf8.decode` rejects both shapes.
// Build standard UTF-8 directly from the UTF-16 source instead.
std::string utf16_to_utf8(const jchar* chars, jsize len) {
std::string out;
out.reserve((size_t)len);
for (jsize i = 0; i < len; i++) {
uint32_t cp = chars[i];
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < len) {
uint32_t lo = chars[i + 1];
if (lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
i++;
}
}
if (cp < 0x80) {
out.push_back((char)cp);
} else if (cp < 0x800) {
out.push_back((char)(0xC0 | (cp >> 6)));
out.push_back((char)(0x80 | (cp & 0x3F)));
} else if (cp < 0x10000) {
out.push_back((char)(0xE0 | (cp >> 12)));
out.push_back((char)(0x80 | ((cp >> 6) & 0x3F)));
out.push_back((char)(0x80 | (cp & 0x3F)));
} else {
out.push_back((char)(0xF0 | (cp >> 18)));
out.push_back((char)(0x80 | ((cp >> 12) & 0x3F)));
out.push_back((char)(0x80 | ((cp >> 6) & 0x3F)));
out.push_back((char)(0x80 | (cp & 0x3F)));
}
}
return out;
}
std::string jstring_to_utf8(JNIEnv* env, jstring s) {
if (s == nullptr) return {};
const jchar* chars = env->GetStringChars(s, nullptr);
if (chars == nullptr) return {};
jsize len = env->GetStringLength(s);
std::string out = utf16_to_utf8(chars, len);
env->ReleaseStringChars(s, chars);
return out;
}
std::string canonical_web_url(const std::string& match) {
// If a scheme is already present (`x://...`), pass through. Otherwise
// prepend `http://` — Patterns.WEB_URL matches bare domains and `www.`.
for (size_t i = 0; i < match.size(); i++) {
char c = match[i];
if (c == ':') {
if (i + 2 < match.size() && match[i + 1] == '/' && match[i + 2] == '/') {
return match;
}
break;
}
if (!(std::isalpha((unsigned char)c) || std::isdigit((unsigned char)c) ||
c == '+' || c == '.' || c == '-')) {
break;
}
}
return std::string("http://") + match;
}
std::string canonical_phone(const std::string& match) {
std::string out = "tel:";
for (char c : match) {
if ((c >= '0' && c <= '9') || c == '+') out.push_back(c);
}
return out;
}
void run_pattern(JNIEnv* env, jstring text, jobject pattern, uint32_t kind,
std::vector<RawMatch>& out) {
jobject matcher = env->CallObjectMethod(pattern, g_matcher_method, text);
if (env->ExceptionCheck() || matcher == nullptr) {
clear_exception(env);
return;
}
while (true) {
jboolean more = env->CallBooleanMethod(matcher, g_find_method);
if (env->ExceptionCheck()) { clear_exception(env); break; }
if (!more) break;
jint start = env->CallIntMethod(matcher, g_start_method);
if (env->ExceptionCheck()) { clear_exception(env); break; }
jint end = env->CallIntMethod(matcher, g_end_method);
if (env->ExceptionCheck()) { clear_exception(env); break; }
jobject groupObj = env->CallObjectMethod(matcher, g_group_method);
if (env->ExceptionCheck()) {
clear_exception(env);
if (groupObj != nullptr) env->DeleteLocalRef(groupObj);
break;
}
if (groupObj == nullptr) continue;
std::string group = jstring_to_utf8(env, (jstring)groupObj);
env->DeleteLocalRef(groupObj);
std::string url;
if (kind == kKindWeb) {
url = canonical_web_url(group);
} else if (kind == kKindEmail) {
url = std::string("mailto:") + group;
} else if (kind == kKindPhone) {
url = canonical_phone(group);
}
out.push_back(RawMatch{(int32_t)start, (int32_t)end, kind, std::move(url)});
}
env->DeleteLocalRef(matcher);
}
void sort_and_dedup(std::vector<RawMatch>& m) {
std::sort(m.begin(), m.end(), [](const RawMatch& a, const RawMatch& b) {
if (a.start != b.start) return a.start < b.start;
int32_t la = a.end - a.start;
int32_t lb = b.end - b.start;
if (la != lb) return la > lb;
return a.kind > b.kind;
});
std::vector<RawMatch> kept;
kept.reserve(m.size());
int32_t lastEnd = 0;
bool any = false;
for (auto& it : m) {
if (any && it.start < lastEnd) continue;
kept.push_back(std::move(it));
lastEnd = kept.back().end;
any = true;
}
m.swap(kept);
}
uint8_t* serialize(const std::vector<RawMatch>& m, int32_t* out_size) {
size_t total = 4;
for (auto& it : m) total += 16 + it.url.size();
uint8_t* buf = (uint8_t*)malloc(total);
if (buf == nullptr) {
if (out_size) *out_size = 0;
return nullptr;
}
uint32_t count = (uint32_t)m.size();
memcpy(buf, &count, 4);
size_t off = 4;
for (auto& it : m) {
int32_t start = it.start;
int32_t end = it.end;
uint32_t kind = it.kind;
uint32_t urlLen = (uint32_t)it.url.size();
memcpy(buf + off + 0, &start, 4);
memcpy(buf + off + 4, &end, 4);
memcpy(buf + off + 8, &kind, 4);
memcpy(buf + off + 12, &urlLen, 4);
if (urlLen != 0) memcpy(buf + off + 16, it.url.data(), urlLen);
off += 16 + urlLen;
}
if (out_size) *out_size = (int32_t)total;
return buf;
}
} // namespace
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /*reserved*/) {
g_vm = vm;
JNIEnv* env;
if (vm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR;
init_jni(env); // Best-effort; ux_match_url retries on each call if this fails.
return JNI_VERSION_1_6;
}
extern "C" __attribute__((visibility("default")))
uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size) {
if (out_size) *out_size = 0;
if (utf16 == nullptr || len <= 0) return nullptr;
if (g_vm == nullptr) return nullptr;
JNIEnv* env = nullptr;
bool attached = false;
jint getEnvResult = g_vm->GetEnv((void**)&env, JNI_VERSION_1_6);
if (getEnvResult == JNI_EDETACHED) {
if (g_vm->AttachCurrentThreadAsDaemon(&env, nullptr) != JNI_OK) {
UX_LOGE("AttachCurrentThreadAsDaemon failed");
return nullptr;
}
attached = true;
} else if (getEnvResult != JNI_OK) {
UX_LOGE("GetEnv failed: %d", getEnvResult);
return nullptr;
}
uint8_t* buf = nullptr;
do {
if (!init_jni(env)) {
clear_exception(env);
UX_LOGE("init_jni failed");
break;
}
jstring text = env->NewString((const jchar*)utf16, len);
if (text == nullptr) {
clear_exception(env);
break;
}
std::vector<RawMatch> matches;
matches.reserve(8);
run_pattern(env, text, g_web_url_pattern, kKindWeb, matches);
run_pattern(env, text, g_email_pattern, kKindEmail, matches);
run_pattern(env, text, g_phone_pattern, kKindPhone, matches);
env->DeleteLocalRef(text);
sort_and_dedup(matches);
if (matches.empty()) break;
buf = serialize(matches, out_size);
} while (false);
if (attached) g_vm->DetachCurrentThread();
return buf;
}
extern "C" __attribute__((visibility("default")))
void ux_free(uint8_t* buf) {
if (buf != nullptr) free(buf);
}

View File

@@ -3,7 +3,7 @@ package io.swipelab.ux
/// JNI bridge to the C globals that Dart reads via FFI.
object KeyboardBridge {
init {
System.loadLibrary("ux_keyboard")
System.loadLibrary("ux")
}
@JvmStatic external fun nSetHeight(h: Double)

View File

@@ -2,7 +2,7 @@ package io.swipelab.ux
object SensorBridge {
init {
System.loadLibrary("ux_keyboard")
System.loadLibrary("ux")
}
@JvmStatic external fun nSetDeviceOrientation(v: Int)

View File

@@ -0,0 +1,71 @@
package io.swipelab.ux
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class UrlPlugin : NativePlugin, MethodChannel.MethodCallHandler {
companion object {
init {
// Trigger JNI_OnLoad in libux.so so the FFI shim's
// android.util.Patterns bindings are cached before the first
// UxUrl.match call from Dart.
System.loadLibrary("ux")
}
}
private var methodChannel: MethodChannel? = null
private var appContext: Context? = null
private var activity: android.app.Activity? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
appContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, "ux/url").also {
it.setMethodCallHandler(this)
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
appContext = null
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"launch" -> handleLaunch(call, result)
else -> result.notImplemented()
}
}
private fun handleLaunch(call: MethodCall, result: MethodChannel.Result) {
val url = call.argument<String>("url")
if (url.isNullOrEmpty()) return result.success(false)
val ctx = activity ?: appContext ?: return result.success(false)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
if (ctx !is android.app.Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
ctx.startActivity(intent)
result.success(true)
} catch (_: ActivityNotFoundException) {
result.success(false)
} catch (_: SecurityException) {
result.success(false)
}
}
}

View File

@@ -15,6 +15,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
GalleryPlugin(),
CameraPlugin(),
CrashPlugin(),
UrlPlugin(),
)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =

View File

@@ -185,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:

View File

@@ -0,0 +1,36 @@
import Flutter
import UIKit
public class UrlPlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel?
public func register(with registrar: FlutterPluginRegistrar) {
let c = FlutterMethodChannel(name: "ux/url", binaryMessenger: registrar.messenger())
c.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
channel = c
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "launch": handleLaunch(call, result: result)
default: result(FlutterMethodNotImplemented)
}
}
private func handleLaunch(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let s = args["url"] as? String,
let url = URL(string: s) else {
return result(false)
}
DispatchQueue.main.async {
let app = UIApplication.shared
guard app.canOpenURL(url) else { return result(false) }
app.open(url, options: [:]) { ok in
result(ok)
}
}
}
}

View File

@@ -14,6 +14,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
GalleryPlugin(),
CrashPlugin(),
CameraPlugin(),
UrlPlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)

211
ios/Classes/url_detect.m Normal file
View File

@@ -0,0 +1,211 @@
// Native data detection for UxUrl. Synchronous, callable via dart:ffi.
//
// Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
// void ux_free(uint8_t* buf);
//
// Output buffer layout (little-endian on Apple ARM64 / x86_64):
// u32 count
// count * { i32 start, i32 end, u32 kind, u32 url_len, u8[url_len] url_utf8 }
// where kind { 0=web, 1=email, 2=phone }.
//
// Backed by NSDataDetector (.link | .phoneNumber). The detector itself
// won't flag bare domains like `google.com` (Apple's stock apps make
// the same trade-off), so we run a second pass with a tight regex that
// requires a `/` or `?` after the domain that pulls in `example.com/path`
// without dragging in `etc.` / `v1.2.3` / `Mr.Smith` false positives.
#import <Foundation/Foundation.h>
#import <stdint.h>
#import <stdlib.h>
#import <string.h>
static const uint32_t kKindWeb = 0;
static const uint32_t kKindEmail = 1;
static const uint32_t kKindPhone = 2;
@interface UxUrlRawMatch : NSObject
@property (nonatomic) int32_t start;
@property (nonatomic) int32_t end;
@property (nonatomic) uint32_t kind;
@property (nonatomic, copy) NSData *urlUtf8;
@end
@implementation UxUrlRawMatch
@end
static NSDataDetector *ux_url_data_detector(void) {
static NSDataDetector *detector;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSError *err = nil;
NSTextCheckingTypes types =
NSTextCheckingTypeLink | NSTextCheckingTypePhoneNumber;
detector = [NSDataDetector dataDetectorWithTypes:types error:&err];
});
return detector;
}
static NSRegularExpression *ux_url_bare_domain_regex(void) {
static NSRegularExpression *regex;
static dispatch_once_t once;
dispatch_once(&once, ^{
// Hostname segment + at least one dot-segment + `/` or `?` suffix.
// ASCII-only; bare IDN domains stay undetected.
NSString *pattern = @"\\b[a-z0-9-]+(\\.[a-z0-9-]+)+([/?][^\\s]*)";
NSError *err = nil;
regex = [NSRegularExpression
regularExpressionWithPattern:pattern
options:NSRegularExpressionCaseInsensitive
error:&err];
});
return regex;
}
static NSData *ux_utf8(NSString *s) {
return [s dataUsingEncoding:NSUTF8StringEncoding allowLossyConversion:YES];
}
__attribute__((visibility("default")))
uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (out_size) *out_size = 0;
if (utf16 == NULL || len <= 0) return NULL;
@autoreleasepool {
NSString *text = [[NSString alloc] initWithCharacters:(const unichar *)utf16
length:(NSUInteger)len];
if (text.length == 0) return NULL;
NSRange whole = NSMakeRange(0, text.length);
NSMutableArray<UxUrlRawMatch *> *raws = [NSMutableArray array];
NSDataDetector *detector = ux_url_data_detector();
if (detector != nil) {
[detector enumerateMatchesInString:text
options:0
range:whole
usingBlock:^(NSTextCheckingResult *result,
NSMatchingFlags flags,
BOOL *stop) {
if (result == nil) return;
const NSRange r = result.range;
if (r.location == NSNotFound || r.length == 0) return;
NSString *url = nil;
uint32_t kind = kKindWeb;
if (result.resultType == NSTextCheckingTypePhoneNumber) {
NSString *raw = result.phoneNumber ?: @"";
// Compat-decompose so full-width / Arabic-Indic digits
// collapse to ASCII before we filter tel: URIs only
// accept [0-9+] per RFC 3966. Stop at the first
// letter run so "ext.99" extensions don't get fused
// into the dialed number.
NSString *folded = [raw decomposedStringWithCompatibilityMapping];
NSMutableString *digits = [NSMutableString stringWithCapacity:folded.length];
BOOL seenLetter = NO;
for (NSUInteger i = 0; i < folded.length; i++) {
unichar c = [folded characterAtIndex:i];
if ((c >= '0' && c <= '9') || c == '+') {
if (seenLetter) break;
[digits appendFormat:@"%C", c];
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
seenLetter = YES;
}
}
url = [NSString stringWithFormat:@"tel:%@", digits];
kind = kKindPhone;
} else if (result.resultType == NSTextCheckingTypeLink) {
NSURL *u = result.URL;
if (u == nil) return;
NSString *scheme = u.scheme.lowercaseString ?: @"";
kind = [scheme isEqualToString:@"mailto"] ? kKindEmail : kKindWeb;
url = u.absoluteString;
} else {
return;
}
if (url.length == 0) return;
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init];
m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length);
m.kind = kind;
m.urlUtf8 = ux_utf8(url);
[raws addObject:m];
}];
}
NSRegularExpression *bareRe = ux_url_bare_domain_regex();
if (bareRe != nil) {
[bareRe enumerateMatchesInString:text
options:0
range:whole
usingBlock:^(NSTextCheckingResult *result,
NSMatchingFlags flags,
BOOL *stop) {
if (result == nil) return;
const NSRange r = result.range;
if (r.location == NSNotFound || r.length == 0) return;
NSString *substr = [text substringWithRange:r];
NSString *withScheme = [@"http://" stringByAppendingString:substr];
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init];
m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length);
m.kind = kKindWeb;
m.urlUtf8 = ux_utf8(withScheme);
[raws addObject:m];
}];
}
if (raws.count == 0) return NULL;
// Sort: start asc, then length desc, then kind desc (phone > email > web on tie).
[raws sortUsingComparator:^NSComparisonResult(UxUrlRawMatch *a, UxUrlRawMatch *b) {
if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending;
int32_t la = a.end - a.start;
int32_t lb = b.end - b.start;
if (la != lb) return la > lb ? NSOrderedAscending : NSOrderedDescending;
if (a.kind != b.kind) return a.kind > b.kind ? NSOrderedAscending : NSOrderedDescending;
return NSOrderedSame;
}];
// Greedy de-overlap.
NSMutableArray<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
int32_t lastEnd = 0;
BOOL haveAny = NO;
for (UxUrlRawMatch *m in raws) {
if (haveAny && m.start < lastEnd) continue;
[kept addObject:m];
lastEnd = m.end;
haveAny = YES;
}
NSUInteger total = 4;
for (UxUrlRawMatch *m in kept) {
total += 16 + (NSUInteger)m.urlUtf8.length;
}
uint8_t *buf = (uint8_t *)malloc(total);
if (buf == NULL) return NULL;
uint32_t cnt = (uint32_t)kept.count;
memcpy(buf, &cnt, 4);
NSUInteger off = 4;
for (UxUrlRawMatch *m in kept) {
int32_t start = m.start;
int32_t end = m.end;
uint32_t kind = m.kind;
uint32_t urlLen = (uint32_t)m.urlUtf8.length;
memcpy(buf + off + 0, &start, 4);
memcpy(buf + off + 4, &end, 4);
memcpy(buf + off + 8, &kind, 4);
memcpy(buf + off + 12, &urlLen, 4);
memcpy(buf + off + 16, m.urlUtf8.bytes, urlLen);
off += 16 + urlLen;
}
if (out_size) *out_size = (int32_t)total;
return buf;
}
}
__attribute__((visibility("default")))
void ux_free(uint8_t *buf) {
if (buf != NULL) free(buf);
}

View File

@@ -29,7 +29,7 @@ mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"
CMD
}]
s.source_files = 'Classes/**/*.swift'
s.source_files = 'Classes/**/*.{swift,m}'
s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo', 'Photos', 'PhotosUI']
s.dependency 'Flutter'
s.ios.deployment_target = '13.0'

58
lib/src/_ffi.dart Normal file
View 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;
}
}

View File

@@ -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);
}

View File

@@ -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
View 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;
}

View File

@@ -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';

View File

@@ -0,0 +1,31 @@
import FlutterMacOS
import AppKit
public class UrlPlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel?
public func register(with registrar: FlutterPluginRegistrar) {
let c = FlutterMethodChannel(name: "ux/url", binaryMessenger: registrar.messenger)
c.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
channel = c
}
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "launch": handleLaunch(call, result: result)
default: result(FlutterMethodNotImplemented)
}
}
private func handleLaunch(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let s = args["url"] as? String,
let url = URL(string: s) else {
return result(false)
}
let ok = NSWorkspace.shared.open(url)
result(ok)
}
}

View File

@@ -10,6 +10,7 @@ public class UxPlugin: NSObject, FlutterPlugin {
ClipboardPlugin(),
GalleryPlugin(),
CameraPlugin(),
UrlPlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)

211
macos/Classes/url_detect.m Normal file
View File

@@ -0,0 +1,211 @@
// Native data detection for UxUrl. Synchronous, callable via dart:ffi.
//
// Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
// void ux_free(uint8_t* buf);
//
// Output buffer layout (little-endian on Apple ARM64 / x86_64):
// u32 count
// count * { i32 start, i32 end, u32 kind, u32 url_len, u8[url_len] url_utf8 }
// where kind { 0=web, 1=email, 2=phone }.
//
// Backed by NSDataDetector (.link | .phoneNumber). The detector itself
// won't flag bare domains like `google.com` (Apple's stock apps make
// the same trade-off), so we run a second pass with a tight regex that
// requires a `/` or `?` after the domain that pulls in `example.com/path`
// without dragging in `etc.` / `v1.2.3` / `Mr.Smith` false positives.
#import <Foundation/Foundation.h>
#import <stdint.h>
#import <stdlib.h>
#import <string.h>
static const uint32_t kKindWeb = 0;
static const uint32_t kKindEmail = 1;
static const uint32_t kKindPhone = 2;
@interface UxUrlRawMatch : NSObject
@property (nonatomic) int32_t start;
@property (nonatomic) int32_t end;
@property (nonatomic) uint32_t kind;
@property (nonatomic, copy) NSData *urlUtf8;
@end
@implementation UxUrlRawMatch
@end
static NSDataDetector *ux_url_data_detector(void) {
static NSDataDetector *detector;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSError *err = nil;
NSTextCheckingTypes types =
NSTextCheckingTypeLink | NSTextCheckingTypePhoneNumber;
detector = [NSDataDetector dataDetectorWithTypes:types error:&err];
});
return detector;
}
static NSRegularExpression *ux_url_bare_domain_regex(void) {
static NSRegularExpression *regex;
static dispatch_once_t once;
dispatch_once(&once, ^{
// Hostname segment + at least one dot-segment + `/` or `?` suffix.
// ASCII-only; bare IDN domains stay undetected.
NSString *pattern = @"\\b[a-z0-9-]+(\\.[a-z0-9-]+)+([/?][^\\s]*)";
NSError *err = nil;
regex = [NSRegularExpression
regularExpressionWithPattern:pattern
options:NSRegularExpressionCaseInsensitive
error:&err];
});
return regex;
}
static NSData *ux_utf8(NSString *s) {
return [s dataUsingEncoding:NSUTF8StringEncoding allowLossyConversion:YES];
}
__attribute__((visibility("default")))
uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (out_size) *out_size = 0;
if (utf16 == NULL || len <= 0) return NULL;
@autoreleasepool {
NSString *text = [[NSString alloc] initWithCharacters:(const unichar *)utf16
length:(NSUInteger)len];
if (text.length == 0) return NULL;
NSRange whole = NSMakeRange(0, text.length);
NSMutableArray<UxUrlRawMatch *> *raws = [NSMutableArray array];
NSDataDetector *detector = ux_url_data_detector();
if (detector != nil) {
[detector enumerateMatchesInString:text
options:0
range:whole
usingBlock:^(NSTextCheckingResult *result,
NSMatchingFlags flags,
BOOL *stop) {
if (result == nil) return;
const NSRange r = result.range;
if (r.location == NSNotFound || r.length == 0) return;
NSString *url = nil;
uint32_t kind = kKindWeb;
if (result.resultType == NSTextCheckingTypePhoneNumber) {
NSString *raw = result.phoneNumber ?: @"";
// Compat-decompose so full-width / Arabic-Indic digits
// collapse to ASCII before we filter tel: URIs only
// accept [0-9+] per RFC 3966. Stop at the first
// letter run so "ext.99" extensions don't get fused
// into the dialed number.
NSString *folded = [raw decomposedStringWithCompatibilityMapping];
NSMutableString *digits = [NSMutableString stringWithCapacity:folded.length];
BOOL seenLetter = NO;
for (NSUInteger i = 0; i < folded.length; i++) {
unichar c = [folded characterAtIndex:i];
if ((c >= '0' && c <= '9') || c == '+') {
if (seenLetter) break;
[digits appendFormat:@"%C", c];
} else if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
seenLetter = YES;
}
}
url = [NSString stringWithFormat:@"tel:%@", digits];
kind = kKindPhone;
} else if (result.resultType == NSTextCheckingTypeLink) {
NSURL *u = result.URL;
if (u == nil) return;
NSString *scheme = u.scheme.lowercaseString ?: @"";
kind = [scheme isEqualToString:@"mailto"] ? kKindEmail : kKindWeb;
url = u.absoluteString;
} else {
return;
}
if (url.length == 0) return;
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init];
m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length);
m.kind = kind;
m.urlUtf8 = ux_utf8(url);
[raws addObject:m];
}];
}
NSRegularExpression *bareRe = ux_url_bare_domain_regex();
if (bareRe != nil) {
[bareRe enumerateMatchesInString:text
options:0
range:whole
usingBlock:^(NSTextCheckingResult *result,
NSMatchingFlags flags,
BOOL *stop) {
if (result == nil) return;
const NSRange r = result.range;
if (r.location == NSNotFound || r.length == 0) return;
NSString *substr = [text substringWithRange:r];
NSString *withScheme = [@"http://" stringByAppendingString:substr];
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init];
m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length);
m.kind = kKindWeb;
m.urlUtf8 = ux_utf8(withScheme);
[raws addObject:m];
}];
}
if (raws.count == 0) return NULL;
// Sort: start asc, then length desc, then kind desc (phone > email > web on tie).
[raws sortUsingComparator:^NSComparisonResult(UxUrlRawMatch *a, UxUrlRawMatch *b) {
if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending;
int32_t la = a.end - a.start;
int32_t lb = b.end - b.start;
if (la != lb) return la > lb ? NSOrderedAscending : NSOrderedDescending;
if (a.kind != b.kind) return a.kind > b.kind ? NSOrderedAscending : NSOrderedDescending;
return NSOrderedSame;
}];
// Greedy de-overlap.
NSMutableArray<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
int32_t lastEnd = 0;
BOOL haveAny = NO;
for (UxUrlRawMatch *m in raws) {
if (haveAny && m.start < lastEnd) continue;
[kept addObject:m];
lastEnd = m.end;
haveAny = YES;
}
NSUInteger total = 4;
for (UxUrlRawMatch *m in kept) {
total += 16 + (NSUInteger)m.urlUtf8.length;
}
uint8_t *buf = (uint8_t *)malloc(total);
if (buf == NULL) return NULL;
uint32_t cnt = (uint32_t)kept.count;
memcpy(buf, &cnt, 4);
NSUInteger off = 4;
for (UxUrlRawMatch *m in kept) {
int32_t start = m.start;
int32_t end = m.end;
uint32_t kind = m.kind;
uint32_t urlLen = (uint32_t)m.urlUtf8.length;
memcpy(buf + off + 0, &start, 4);
memcpy(buf + off + 4, &end, 4);
memcpy(buf + off + 8, &kind, 4);
memcpy(buf + off + 12, &urlLen, 4);
memcpy(buf + off + 16, m.urlUtf8.bytes, urlLen);
off += 16 + urlLen;
}
if (out_size) *out_size = (int32_t)total;
return buf;
}
}
__attribute__((visibility("default")))
void ux_free(uint8_t *buf) {
if (buf != NULL) free(buf);
}

View File

@@ -27,7 +27,7 @@ mkdir -p "$DEST"
rsync -a --delete "$SRC/" "$DEST/"
CMD
}]
s.source_files = 'Classes/**/*.swift'
s.source_files = 'Classes/**/*.{swift,m}'
s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo']
s.dependency 'FlutterMacOS'
s.osx.deployment_target = '10.15'

View File

@@ -177,6 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:

View File

@@ -22,6 +22,7 @@ dependencies:
matcher: ^0.12.16
build: ^2.4.0
clock: ^1.1.1
ffi: ^2.1.0
dev_dependencies:
flutter_lints: ^6.0.0