feat(sdk): Push deep link routing for all platforms
- TypeScript: DeepLinkRouter with URL parsing and handler registration - Swift: BLDeepLinkRouter with iOS URL handling and Logger integration - Kotlin: DeepLinkRouter with Android Uri parsing and handler mapping - Common screen constants: broadcasts, surveys, settings, profile, etc.
This commit is contained in:
parent
6e0b6c33c9
commit
18dd263797
161
packages/broadcast-client/src/deep-link.ts
Normal file
161
packages/broadcast-client/src/deep-link.ts
Normal file
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Deep Link Router — TypeScript
|
||||
* Handles routing from push notification deep links to app screens
|
||||
*/
|
||||
|
||||
export interface DeepLinkRoute {
|
||||
screen: string;
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type DeepLinkHandler = (route: DeepLinkRoute) => void;
|
||||
|
||||
/**
|
||||
* Deep Link Router class
|
||||
*/
|
||||
export class DeepLinkRouter {
|
||||
private handlers = new Map<string, DeepLinkHandler>();
|
||||
private fallbackHandler?: DeepLinkHandler;
|
||||
|
||||
/**
|
||||
* Register a handler for a specific screen
|
||||
*/
|
||||
register(screen: string, handler: DeepLinkHandler): void {
|
||||
this.handlers.set(screen, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a fallback handler for unregistered screens
|
||||
*/
|
||||
setFallback(handler: DeepLinkHandler): void {
|
||||
this.fallbackHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a deep link URL and extract route
|
||||
*/
|
||||
parseDeepLink(url: string): DeepLinkRoute | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Handle app-specific URLs: myapp://screen/params
|
||||
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
const screen = pathParts[0] || 'home';
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// Parse query params
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
return { screen, params };
|
||||
}
|
||||
|
||||
// Handle web URLs with deep link params
|
||||
const deepLinkParam = urlObj.searchParams.get('dl');
|
||||
if (deepLinkParam) {
|
||||
return this.parseDeepLink(deepLinkParam);
|
||||
}
|
||||
|
||||
// Handle path-based routing: /screen/params
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length > 0) {
|
||||
const screen = pathParts[0];
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
urlObj.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
return { screen, params };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a deep link route
|
||||
*/
|
||||
handle(route: DeepLinkRoute): boolean {
|
||||
const handler = this.handlers.get(route.screen);
|
||||
|
||||
if (handler) {
|
||||
handler(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.fallbackHandler) {
|
||||
this.fallbackHandler(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn(`[DeepLink] No handler for screen: ${route.screen}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a deep link URL end-to-end
|
||||
*/
|
||||
process(url: string): boolean {
|
||||
const route = this.parseDeepLink(url);
|
||||
if (!route) {
|
||||
console.warn(`[DeepLink] Failed to parse: ${url}`);
|
||||
return false;
|
||||
}
|
||||
return this.handle(route);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a broadcast deep link URL
|
||||
*/
|
||||
export function createBroadcastDeepLink(
|
||||
baseUrl: string,
|
||||
screen: string,
|
||||
params?: Record<string, string>,
|
||||
broadcastId?: string
|
||||
): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = `/${screen}`;
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (broadcastId) {
|
||||
url.searchParams.set('broadcastId', broadcastId);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Common deep link screens for broadcast/survey flows
|
||||
*/
|
||||
export const DeepLinkScreens = {
|
||||
// Broadcasts
|
||||
BROADCAST_DETAIL: 'broadcast',
|
||||
ANNOUNCEMENTS: 'announcements',
|
||||
|
||||
// Surveys
|
||||
SURVEY: 'survey',
|
||||
SURVEY_LIST: 'surveys',
|
||||
|
||||
// Product-specific (examples)
|
||||
SETTINGS: 'settings',
|
||||
PROFILE: 'profile',
|
||||
UPGRADE: 'upgrade',
|
||||
SUPPORT: 'support',
|
||||
|
||||
// Fallback
|
||||
HOME: 'home',
|
||||
} as const;
|
||||
|
||||
// Singleton instance for app-wide use
|
||||
export const deepLinkRouter = new DeepLinkRouter();
|
||||
@ -169,3 +169,17 @@ export function createUseBroadcast(client: BroadcastClient) {
|
||||
return { client };
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deep Link Router
|
||||
// =============================================================================
|
||||
|
||||
export {
|
||||
DeepLinkRouter,
|
||||
deepLinkRouter,
|
||||
DeepLinkScreens,
|
||||
createBroadcastDeepLink,
|
||||
type DeepLinkRoute,
|
||||
type DeepLinkHandler,
|
||||
} from './deep-link.js';
|
||||
|
||||
|
||||
@ -20,6 +20,16 @@ import { BreadcrumbTrail } from './breadcrumbs.js';
|
||||
import { NetworkInterceptor } from './network.js';
|
||||
import { collectDeviceState } from './device.js';
|
||||
|
||||
// DOM type declarations for ESLint
|
||||
type ErrorEvent = {
|
||||
message: string;
|
||||
filename: string;
|
||||
lineno: number;
|
||||
colno: number;
|
||||
error?: { stack?: string };
|
||||
};
|
||||
type EventListener = (event: unknown) => void;
|
||||
|
||||
export interface DiagnosticsClientOptions extends DiagnosticsConfig {
|
||||
/** Custom logger */
|
||||
logger?: {
|
||||
|
||||
@ -6,6 +6,26 @@
|
||||
|
||||
import type { DeviceState } from './types.js';
|
||||
|
||||
// DOM type declarations for ESLint
|
||||
declare const navigator: Navigator & {
|
||||
connection?: NetworkInformation;
|
||||
getBattery?: () => Promise<BatteryManager>;
|
||||
storage?: { estimate(): Promise<{ usage?: number }> };
|
||||
};
|
||||
declare const performance: Performance & { memory?: { usedJSHeapSize: number } };
|
||||
declare const window: Window & {
|
||||
addEventListener: (type: string, listener: EventListener) => void;
|
||||
removeEventListener: (type: string, listener: EventListener) => void;
|
||||
};
|
||||
type EventListener = (event: { isTrusted: boolean }) => void;
|
||||
interface NetworkInformation {
|
||||
effectiveType?: string;
|
||||
}
|
||||
interface BatteryManager {
|
||||
charging: boolean;
|
||||
level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect current device state
|
||||
* Best-effort: some APIs may not be available in all environments
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
|
||||
import type { NetworkRequest } from './types.js';
|
||||
|
||||
// DOM type declarations for ESLint
|
||||
type RequestInfo = string | Request | URL;
|
||||
type HeadersInit = Headers | Record<string, string> | string[][];
|
||||
|
||||
export interface NetworkInterceptorOptions {
|
||||
/** URL patterns to include (default: all) */
|
||||
includePatterns?: RegExp[];
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
package com.bytelyst.platform
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Deep Link Route data class
|
||||
*/
|
||||
data class DeepLinkRoute(
|
||||
val screen: String,
|
||||
val params: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Deep link handler type alias
|
||||
*/
|
||||
typealias DeepLinkHandler = (DeepLinkRoute) -> Unit
|
||||
|
||||
/**
|
||||
* Deep Link Router class
|
||||
* Handles routing from push notification deep links to app screens
|
||||
*/
|
||||
class DeepLinkRouter {
|
||||
private val handlers = mutableMapOf<String, DeepLinkHandler>()
|
||||
private var fallbackHandler: DeepLinkHandler? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DeepLinkRouter"
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a specific screen
|
||||
*/
|
||||
fun register(screen: String, handler: DeepLinkHandler) {
|
||||
handlers[screen] = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a fallback handler for unregistered screens
|
||||
*/
|
||||
fun setFallback(handler: DeepLinkHandler) {
|
||||
fallbackHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a deep link URL and extract route
|
||||
*/
|
||||
fun parseDeepLink(urlString: String): DeepLinkRoute? {
|
||||
return try {
|
||||
val uri = Uri.parse(urlString)
|
||||
|
||||
// Handle app-specific URLs: myapp://screen/params
|
||||
if (uri.scheme != "http" && uri.scheme != "https") {
|
||||
val pathSegments = uri.pathSegments
|
||||
val screen = pathSegments.firstOrNull() ?: "home"
|
||||
|
||||
val params = mutableMapOf<String, String>()
|
||||
uri.queryParameterNames.forEach { key ->
|
||||
uri.getQueryParameter(key)?.let { value ->
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
DeepLinkRoute(screen, params)
|
||||
}
|
||||
// Handle web URLs with deep link params
|
||||
else if (uri.getQueryParameter("dl") != null) {
|
||||
parseDeepLink(uri.getQueryParameter("dl")!!)
|
||||
}
|
||||
// Handle path-based routing: /screen/params
|
||||
else {
|
||||
val pathSegments = uri.pathSegments
|
||||
if (pathSegments.isNotEmpty()) {
|
||||
val screen = pathSegments[0]
|
||||
|
||||
val params = mutableMapOf<String, String>()
|
||||
uri.queryParameterNames.forEach { key ->
|
||||
uri.getQueryParameter(key)?.let { value ->
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
DeepLinkRoute(screen, params)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse deep link: $urlString", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a deep link route
|
||||
*/
|
||||
fun handle(route: DeepLinkRoute): Boolean {
|
||||
val handler = handlers[route.screen]
|
||||
|
||||
return if (handler != null) {
|
||||
handler(route)
|
||||
true
|
||||
} else if (fallbackHandler != null) {
|
||||
fallbackHandler?.invoke(route)
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "No handler for screen: ${route.screen}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a deep link URL end-to-end
|
||||
*/
|
||||
fun process(urlString: String): Boolean {
|
||||
val route = parseDeepLink(urlString)
|
||||
return if (route != null) {
|
||||
handle(route)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse deep link: $urlString")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a broadcast deep link URL
|
||||
*/
|
||||
fun createBroadcastDeepLink(
|
||||
baseUrl: String,
|
||||
screen: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
broadcastId: String? = null
|
||||
): String {
|
||||
val uriBuilder = Uri.parse(baseUrl).buildUpon()
|
||||
.path("/$screen")
|
||||
|
||||
params.forEach { (key, value) ->
|
||||
uriBuilder.appendQueryParameter(key, value)
|
||||
}
|
||||
|
||||
broadcastId?.let {
|
||||
uriBuilder.appendQueryParameter("broadcastId", it)
|
||||
}
|
||||
|
||||
return uriBuilder.build().toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Common deep link screens
|
||||
*/
|
||||
object DeepLinkScreens {
|
||||
// Broadcasts
|
||||
const val BROADCAST = "broadcast"
|
||||
const val ANNOUNCEMENTS = "announcements"
|
||||
|
||||
// Surveys
|
||||
const val SURVEY = "survey"
|
||||
const val SURVEY_LIST = "surveys"
|
||||
|
||||
// Product-specific
|
||||
const val SETTINGS = "settings"
|
||||
const val PROFILE = "profile"
|
||||
const val UPGRADE = "upgrade"
|
||||
const val SUPPORT = "support"
|
||||
|
||||
// Fallback
|
||||
const val HOME = "home"
|
||||
}
|
||||
|
||||
// Singleton instance for app-wide use
|
||||
val deepLinkRouter = DeepLinkRouter()
|
||||
181
packages/swift-platform-sdk/Sources/BLDeepLinkRouter.swift
Normal file
181
packages/swift-platform-sdk/Sources/BLDeepLinkRouter.swift
Normal file
@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
* Deep Link Router — Swift
|
||||
* Handles routing from push notification deep links to app screens
|
||||
*/
|
||||
|
||||
public struct BLDeepLinkRoute {
|
||||
public let screen: String
|
||||
public let params: [String: String]
|
||||
|
||||
public init(screen: String, params: [String: String] = [:]) {
|
||||
self.screen = screen
|
||||
self.params = params
|
||||
}
|
||||
}
|
||||
|
||||
public typealias BLDeepLinkHandler = (BLDeepLinkRoute) -> Void
|
||||
|
||||
/**
|
||||
* Deep Link Router class
|
||||
*/
|
||||
@available(iOS 15.0, *)
|
||||
public class BLDeepLinkRouter {
|
||||
private var handlers: [String: BLDeepLinkHandler] = [:]
|
||||
private var fallbackHandler: BLDeepLinkHandler?
|
||||
|
||||
public init() {}
|
||||
|
||||
/**
|
||||
* Register a handler for a specific screen
|
||||
*/
|
||||
public func register(screen: String, handler: @escaping BLDeepLinkHandler) {
|
||||
handlers[screen] = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a fallback handler for unregistered screens
|
||||
*/
|
||||
public func setFallback(handler: @escaping BLDeepLinkHandler) {
|
||||
fallbackHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a deep link URL and extract route
|
||||
*/
|
||||
public func parseDeepLink(_ urlString: String) -> BLDeepLinkRoute? {
|
||||
guard let url = URL(string: urlString) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle app-specific URLs: myapp://screen/params
|
||||
if url.scheme != "http" && url.scheme != "https" {
|
||||
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
|
||||
let screen = pathComponents.first ?? "home"
|
||||
|
||||
var params: [String: String] = [:]
|
||||
if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems {
|
||||
for item in queryItems {
|
||||
if let value = item.value {
|
||||
params[item.name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BLDeepLinkRoute(screen: screen, params: params)
|
||||
}
|
||||
|
||||
// Handle web URLs with deep link params
|
||||
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let dlParam = components.queryItems?.first(where: { $0.name == "dl" })?.value {
|
||||
return parseDeepLink(dlParam)
|
||||
}
|
||||
|
||||
// Handle path-based routing: /screen/params
|
||||
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
|
||||
if !pathComponents.isEmpty {
|
||||
let screen = pathComponents[0]
|
||||
|
||||
var params: [String: String] = [:]
|
||||
if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems {
|
||||
for item in queryItems {
|
||||
if let value = item.value {
|
||||
params[item.name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BLDeepLinkRoute(screen: screen, params: params)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a deep link route
|
||||
*/
|
||||
@discardableResult
|
||||
public func handle(_ route: BLDeepLinkRoute) -> Bool {
|
||||
if let handler = handlers[route.screen] {
|
||||
handler(route)
|
||||
return true
|
||||
}
|
||||
|
||||
if let fallback = fallbackHandler {
|
||||
fallback(route)
|
||||
return true
|
||||
}
|
||||
|
||||
Logger.deepLink.warning("No handler for screen: \(route.screen)")
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a deep link URL end-to-end
|
||||
*/
|
||||
@discardableResult
|
||||
public func process(_ urlString: String) -> Bool {
|
||||
guard let route = parseDeepLink(urlString) else {
|
||||
Logger.deepLink.warning("Failed to parse deep link: \(urlString)")
|
||||
return false
|
||||
}
|
||||
return handle(route)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a broadcast deep link URL
|
||||
*/
|
||||
public func createBroadcastDeepLink(
|
||||
baseURL: String,
|
||||
screen: String,
|
||||
params: [String: String] = [:],
|
||||
broadcastId: String? = nil
|
||||
) -> String? {
|
||||
guard var components = URLComponents(string: baseURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.path = "/\(screen)"
|
||||
|
||||
var queryItems: [URLQueryItem] = params.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
|
||||
if let broadcastId = broadcastId {
|
||||
queryItems.append(URLQueryItem(name: "broadcastId", value: broadcastId))
|
||||
}
|
||||
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
|
||||
return components.string
|
||||
}
|
||||
|
||||
/**
|
||||
* Common deep link screens
|
||||
*/
|
||||
public struct BLDeepLinkScreens {
|
||||
// Broadcasts
|
||||
public static let broadcast = "broadcast"
|
||||
public static let announcements = "announcements"
|
||||
|
||||
// Surveys
|
||||
public static let survey = "survey"
|
||||
public static let surveyList = "surveys"
|
||||
|
||||
// Product-specific
|
||||
public static let settings = "settings"
|
||||
public static let profile = "profile"
|
||||
public static let upgrade = "upgrade"
|
||||
public static let support = "support"
|
||||
|
||||
// Fallback
|
||||
public static let home = "home"
|
||||
}
|
||||
|
||||
// Logger extension
|
||||
@available(iOS 15.0, *)
|
||||
extension Logger {
|
||||
static let deepLink = Logger(subsystem: "com.bytelyst.platform", category: "DeepLink")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user