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:
@@ -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")
|
||||
|
||||
348
android/src/main/jni/url_detect.cpp
Normal file
348
android/src/main/jni/url_detect.cpp
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,7 @@ package io.swipelab.ux
|
||||
|
||||
object SensorBridge {
|
||||
init {
|
||||
System.loadLibrary("ux_keyboard")
|
||||
System.loadLibrary("ux")
|
||||
}
|
||||
|
||||
@JvmStatic external fun nSetDeviceOrientation(v: Int)
|
||||
|
||||
71
android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt
Normal file
71
android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class UxPlugin : FlutterPlugin, ActivityAware {
|
||||
GalleryPlugin(),
|
||||
CameraPlugin(),
|
||||
CrashPlugin(),
|
||||
UrlPlugin(),
|
||||
)
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) =
|
||||
|
||||
@@ -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:
|
||||
|
||||
36
ios/Classes/UrlPlugin.swift
Normal file
36
ios/Classes/UrlPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
211
ios/Classes/url_detect.m
Normal 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);
|
||||
}
|
||||
@@ -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
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';
|
||||
|
||||
31
macos/Classes/UrlPlugin.swift
Normal file
31
macos/Classes/UrlPlugin.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
211
macos/Classes/url_detect.m
Normal 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);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user